feat: filter/exclude/copy actions on attribute values (#1850)

## Summary
Closes #1829

Adds interactive filter/exclude/copy actions to the Event Deltas attribute comparison charts:

- **Click popover on chart bars**: Clicking a bar in any PropertyComparisonChart opens a popover with the attribute name, value, and Selection/Background percentages, plus action buttons.
- **Filter/Exclude buttons**: Use the existing search sidebar filter system (`setFilterValue`) to include or exclude an attribute value from results. After applying a filter, the heatmap selection is automatically cleared.
- **Copy button**: Copies the raw attribute value to the clipboard.
- **Key conversion utilities**: New `flattenedKeyToSqlExpression` and `flattenedKeyToFilterKey` functions convert dot-notation property keys (produced by `flattenData()`) into ClickHouse bracket notation for Map columns (e.g., `ResourceAttributes.service.name` -> `ResourceAttributes['service.name']`), enabling correct filter integration.

## Changes
- `deltaChartUtils.ts` -- Added `escapeRegExp`, `flattenedKeyToSqlExpression`, `flattenedKeyToFilterKey`, and `AddFilterFn` type
- `PropertyComparisonChart.tsx` -- Added click handler, popover with Filter/Exclude/Copy actions using `createPortal`
- `DBDeltaChart.tsx` -- Added `onAddFilter` prop, `handleAddFilter` callback wrapping key conversion, re-exported `AddFilterFn`
- `DBSearchHeatmapChart.tsx` -- Added `onAddFilter` prop, `handleAddFilterAndClearSelection` callback
- `DBSearchPage.tsx` -- Passed `searchFilters.setFilterValue` as `onAddFilter` to `DBSearchHeatmapChart`
- New test file `deltaChartFilterKeys.test.ts` with 16 tests for key conversion functions

## Test plan
- [x] 16 unit tests for `flattenedKeyToSqlExpression` and `flattenedKeyToFilterKey` (Map columns, Array(Map) columns, simple columns, LowCardinality wrappers, SQL injection escaping)
- [x] All 57 existing delta chart tests still pass
- [x] ESLint passes with no errors
- [x] Manual: click a bar in the delta chart -> popover appears with correct value and percentages
- [x] Manual: click Filter button -> sidebar filter is applied, heatmap selection clears
- [x] Manual: click Exclude button -> exclusion filter is applied
- [x] Manual: click Copy button -> value copied to clipboard, button shows checkmark

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Alex Fedotyev 2026-03-10 10:25:06 -07:00 committed by GitHub
parent ced90ae10e
commit 2efb8fdc52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 444 additions and 69 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: filter/exclude/copy actions on Event Deltas attribute values

View file

@ -1873,6 +1873,7 @@ function DBSearchPage() {
}}
isReady={isReady}
source={searchedSource}
onAddFilter={searchFilters.setFilterValue}
/>
)}
{analysisMode === 'results' && (

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import {
BuilderChartConfigWithDateRange,
@ -20,7 +20,9 @@ import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { getFirstTimestampValueExpression } from '@/source';
import { SQLPreview } from './ChartSQLPreview';
import type { AddFilterFn } from './deltaChartUtils';
import {
flattenedKeyToFilterKey,
getPropertyStatistics,
getStableSampleExpression,
isDenylisted,
@ -36,6 +38,9 @@ import {
PropertyComparisonChart,
} from './PropertyComparisonChart';
// Re-export types so callers importing from DBDeltaChart don't need to change.
export type { AddFilterFn } from './deltaChartUtils';
export default function DBDeltaChart({
config,
valueExpr,
@ -43,6 +48,7 @@ export default function DBDeltaChart({
xMax,
yMin,
yMax,
onAddFilter,
spanIdExpression,
}: {
config: BuilderChartConfigWithDateRange;
@ -51,6 +57,7 @@ export default function DBDeltaChart({
xMax: number;
yMin: number;
yMax: number;
onAddFilter?: AddFilterFn;
spanIdExpression?: string;
}) {
// Determine if the value expression uses aggregate functions
@ -216,6 +223,15 @@ export default function DBDeltaChart({
[outlierData?.meta, inlierData?.meta],
);
// Wrap onAddFilter to convert flattened dot-notation keys into ClickHouse bracket notation
const handleAddFilter = useCallback<NonNullable<AddFilterFn>>(
(property, value, action) => {
if (!onAddFilter) return;
onAddFilter(flattenedKeyToFilterKey(property, columnMeta), value, action);
},
[onAddFilter, columnMeta],
);
// TODO: Is loading state
const {
visibleProperties,
@ -411,6 +427,7 @@ export default function DBDeltaChart({
inlierValueOccurences={
inlierValueOccurences.get(property) ?? new Map()
}
onAddFilter={onAddFilter ? handleAddFilter : undefined}
key={property}
/>
))}
@ -447,6 +464,7 @@ export default function DBDeltaChart({
inlierValueOccurences={
inlierValueOccurences.get(key) ?? new Map()
}
onAddFilter={onAddFilter ? handleAddFilter : undefined}
key={key}
/>
))}

View file

@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useState } from 'react';
import { withErrorBoundary } from 'react-error-boundary';
import type { TooltipProps } from 'recharts';
import {
@ -10,10 +10,18 @@ import {
XAxis,
YAxis,
} from 'recharts';
import { Text } from '@mantine/core';
import { Flex, Popover, Text } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react';
import { getChartColorError, getChartColorSuccess } from '@/utils';
import {
getChartColorError,
getChartColorSuccess,
truncateMiddle,
} from '@/utils';
import { DBRowTableIconButton } from './DBTable/DBRowTableIconButton';
import type { AddFilterFn } from './deltaChartUtils';
import {
applyTopNAggregation,
mergeValueStatisticsMaps,
@ -46,6 +54,7 @@ type TooltipContentProps = TooltipProps<number, string> & {
};
// Hover-only tooltip: shows value name and percentages.
// Actions are handled by the click popover in PropertyComparisonChart.
const HDXBarChartTooltip = withErrorBoundary(
memo(({ active, payload, label, title }: TooltipContentProps) => {
if (active && payload && payload.length) {
@ -94,7 +103,7 @@ function TruncatedTick({ x = 0, y = 0, payload }: TickProps) {
const value = String(payload?.value ?? '');
const MAX_CHARS = 12;
const displayValue =
value.length > MAX_CHARS ? value.slice(0, MAX_CHARS) + '' : value;
value.length > MAX_CHARS ? value.slice(0, MAX_CHARS) + '\u2026' : value;
return (
<g transform={`translate(${x},${y})`}>
<title>{value}</title>
@ -117,10 +126,12 @@ export function PropertyComparisonChart({
name,
outlierValueOccurences,
inlierValueOccurences,
onAddFilter,
}: {
name: string;
outlierValueOccurences: Map<string, number>;
inlierValueOccurences: Map<string, number>;
onAddFilter?: AddFilterFn;
}) {
const mergedValueStatistics = mergeValueStatisticsMaps(
outlierValueOccurences,
@ -128,72 +139,175 @@ export function PropertyComparisonChart({
);
const chartData = applyTopNAggregation(mergedValueStatistics);
const [clickedValue, setClickedValue] = useState<string | null>(null);
const clipboard = useClipboard({ timeout: 2000 });
const handleChartClick = (data: any) => {
if (!data?.activePayload?.length) {
setClickedValue(null);
return;
}
if (data.activePayload[0]?.payload?.isOther) {
setClickedValue(null);
return;
}
const newValue = String(data.activeLabel ?? '');
// Toggle off if clicking the same bar
if (newValue === clickedValue) {
setClickedValue(null);
return;
}
clipboard.reset();
setClickedValue(newValue);
};
return (
<div style={{ width: '100%', height: CHART_HEIGHT }}>
<Text
size="xs"
ta="center"
title={name}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{name}
</Text>
<ResponsiveContainer width="100%" height="100%">
<BarChart
barGap={2}
width={500}
height={300}
data={chartData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
>
<XAxis dataKey="name" tick={<TruncatedTick />} />
<YAxis
tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }}
/>
<Tooltip
content={<HDXBarChartTooltip title={name} />}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
/>
<Bar
dataKey="outlierCount"
name="Selection"
fill={getChartColorError()}
isAnimationActive={false}
<Popover
opened={clickedValue !== null}
onChange={opened => {
if (!opened) setClickedValue(null);
}}
position="top"
withArrow
shadow="md"
>
<Popover.Target>
<div style={{ width: '100%', height: CHART_HEIGHT }}>
<Text
size="xs"
ta="center"
title={name}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chartData.map((entry, index) => (
<Cell
key={`out-${index}`}
fill={entry.isOther ? OTHER_BUCKET_COLOR : getChartColorError()}
{name}
</Text>
<ResponsiveContainer width="100%" height="100%">
<BarChart
barGap={2}
width={500}
height={300}
data={chartData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
onClick={handleChartClick}
style={{ cursor: 'pointer' }}
>
<XAxis dataKey="name" tick={<TruncatedTick />} />
<YAxis
tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }}
/>
))}
</Bar>
<Bar
dataKey="inlierCount"
name="Background"
fill={getChartColorSuccess()}
isAnimationActive={false}
>
{chartData.map((entry, index) => (
<Cell
key={`in-${index}`}
fill={
entry.isOther ? OTHER_BUCKET_COLOR : getChartColorSuccess()
}
<Tooltip
content={<HDXBarChartTooltip title={name} />}
allowEscapeViewBox={{ x: false, y: true }}
wrapperStyle={{ zIndex: 1000 }}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<Bar
dataKey="outlierCount"
name="Selection"
fill={getChartColorError()}
isAnimationActive={false}
>
{chartData.map((entry, index) => (
<Cell
key={`out-${index}`}
fill={
entry.isOther ? OTHER_BUCKET_COLOR : getChartColorError()
}
/>
))}
</Bar>
<Bar
dataKey="inlierCount"
name="Background"
fill={getChartColorSuccess()}
isAnimationActive={false}
>
{chartData.map((entry, index) => (
<Cell
key={`in-${index}`}
fill={
entry.isOther
? OTHER_BUCKET_COLOR
: getChartColorSuccess()
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</Popover.Target>
<Popover.Dropdown p="xs" style={{ fontSize: 11 }}>
{clickedValue !== null && (
<>
<Text
size="xs"
c="dimmed"
fw={600}
mb={4}
style={{ wordBreak: 'break-all' }}
title={name}
>
{truncateMiddle(name, 40)}
</Text>
<Text size="xs" mb={6} style={{ wordBreak: 'break-all' }}>
{clickedValue.length === 0 ? <i>Empty String</i> : clickedValue}
</Text>
<Flex gap={12} mb={8}>
<Text size="xs" c={getChartColorError()}>
Selection:{' '}
{(outlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}%
</Text>
<Text size="xs" c={getChartColorSuccess()}>
Background:{' '}
{(inlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}%
</Text>
</Flex>
<Flex gap={4} align="center">
{onAddFilter && (
<>
<DBRowTableIconButton
variant="copy"
title="Filter for this value"
onClick={() => {
onAddFilter(name, clickedValue, 'include');
setClickedValue(null);
}}
>
<IconFilter size={12} />
</DBRowTableIconButton>
<DBRowTableIconButton
variant="copy"
title="Exclude this value"
onClick={() => {
onAddFilter(name, clickedValue, 'exclude');
setClickedValue(null);
}}
>
<IconFilterX size={12} />
</DBRowTableIconButton>
</>
)}
<DBRowTableIconButton
variant="copy"
title={clipboard.copied ? 'Copied!' : 'Copy value'}
isActive={clipboard.copied}
onClick={() => clipboard.copy(clickedValue)}
>
<IconCopy size={12} />
</DBRowTableIconButton>
</Flex>
</>
)}
</Popover.Dropdown>
</Popover>
);
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { parseAsFloat, parseAsString, useQueryStates } from 'nuqs';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -21,6 +21,7 @@ import { IconPlayerPlay } from '@tabler/icons-react';
import { SQLInlineEditorControlled } from '@/components/SearchInput/SQLInlineEditor';
import { getDurationMsExpression } from '@/source';
import type { AddFilterFn } from '../DBDeltaChart';
import DBDeltaChart from '../DBDeltaChart';
import DBHeatmapChart from '../DBHeatmapChart';
@ -33,10 +34,12 @@ export function DBSearchHeatmapChart({
chartConfig,
source,
isReady,
onAddFilter,
}: {
chartConfig: BuilderChartConfigWithDateRange;
source: TSource;
isReady: boolean;
onAddFilter?: AddFilterFn;
}) {
const [fields, setFields] = useQueryStates({
value: parseAsString.withDefault(getDurationMsExpression(source)),
@ -49,6 +52,18 @@ export function DBSearchHeatmapChart({
});
const [container, setContainer] = useState<HTMLElement | null>(null);
// After applying a filter, clear the heatmap selection so the delta chart
// resets instead of staying in comparison mode.
const handleAddFilterAndClearSelection = useCallback<
NonNullable<AddFilterFn>
>(
(property, value, action) => {
setFields({ xMin: null, xMax: null, yMin: null, yMax: null });
onAddFilter?.(property, value, action);
},
[onAddFilter, setFields],
);
return (
<Flex
direction="column"
@ -119,6 +134,9 @@ export function DBSearchHeatmapChart({
xMax={fields.xMax}
yMin={fields.yMin}
yMax={fields.yMax}
onAddFilter={
onAddFilter ? handleAddFilterAndClearSelection : undefined
}
spanIdExpression={source.spanIdExpression}
/>
) : (

View file

@ -0,0 +1,142 @@
import {
flattenedKeyToFilterKey,
flattenedKeyToSqlExpression,
} from '../deltaChartUtils';
const traceColumnMeta = [
{ name: 'Timestamp', type: 'DateTime64(9)' },
{ name: 'TraceId', type: 'String' },
{ name: 'SpanId', type: 'String' },
{ name: 'ParentSpanId', type: 'String' },
{ name: 'ResourceAttributes', type: 'Map(String, String)' },
{ name: 'SpanAttributes', type: 'Map(String, String)' },
{ name: 'Events.Timestamp', type: 'Array(DateTime64(9))' },
{ name: 'Events.Name', type: 'Array(String)' },
{ name: 'Events.Attributes', type: 'Array(Map(String, String))' },
{ name: 'Links.TraceId', type: 'Array(String)' },
{ name: 'Links.SpanId', type: 'Array(String)' },
{ name: 'Links.Timestamp', type: 'Array(DateTime64(9))' },
{ name: 'Links.Attributes', type: 'Array(Map(String, String))' },
];
describe('flattenedKeyToSqlExpression', () => {
it('converts Map column dot-notation to bracket notation', () => {
expect(
flattenedKeyToSqlExpression(
'ResourceAttributes.service.name',
traceColumnMeta,
),
).toBe("ResourceAttributes['service.name']");
});
it('converts SpanAttributes dot-notation to bracket notation', () => {
expect(
flattenedKeyToSqlExpression(
'SpanAttributes.http.method',
traceColumnMeta,
),
).toBe("SpanAttributes['http.method']");
});
it('converts Array(Map) dot-notation with 0-based index to 1-based bracket notation', () => {
expect(
flattenedKeyToSqlExpression(
'Events.Attributes[0].message.type',
traceColumnMeta,
),
).toBe("Events.Attributes[1]['message.type']");
});
it('increments the array index from 0-based JS to 1-based ClickHouse', () => {
expect(
flattenedKeyToSqlExpression('Events.Attributes[4].key', traceColumnMeta),
).toBe("Events.Attributes[5]['key']");
});
it('returns simple columns unchanged', () => {
expect(flattenedKeyToSqlExpression('TraceId', traceColumnMeta)).toBe(
'TraceId',
);
});
it('returns non-map nested columns unchanged', () => {
expect(flattenedKeyToSqlExpression('Events.Name[0]', traceColumnMeta)).toBe(
'Events.Name[0]',
);
});
it('returns key unchanged when no matching column found', () => {
expect(
flattenedKeyToSqlExpression('SomeUnknownColumn.key', traceColumnMeta),
).toBe('SomeUnknownColumn.key');
});
it('handles LowCardinality(Map) wrapped types', () => {
const meta = [
{ name: 'LogAttributes', type: 'LowCardinality(Map(String, String))' },
];
expect(flattenedKeyToSqlExpression('LogAttributes.level', meta)).toBe(
"LogAttributes['level']",
);
});
it('returns key unchanged for empty columnMeta', () => {
expect(
flattenedKeyToSqlExpression('ResourceAttributes.service.name', []),
).toBe('ResourceAttributes.service.name');
});
it('escapes single quotes in Map column keys to prevent SQL injection', () => {
expect(
flattenedKeyToSqlExpression(
"ResourceAttributes.it's.key",
traceColumnMeta,
),
).toBe("ResourceAttributes['it''s.key']");
});
it('escapes single quotes in Array(Map) column keys', () => {
expect(
flattenedKeyToSqlExpression(
"Events.Attributes[0].it's.key",
traceColumnMeta,
),
).toBe("Events.Attributes[1]['it''s.key']");
});
});
describe('flattenedKeyToFilterKey', () => {
it('converts Map column keys to bracket notation', () => {
expect(
flattenedKeyToFilterKey(
'ResourceAttributes.service.name',
traceColumnMeta,
),
).toBe("ResourceAttributes['service.name']");
});
it('handles multi-segment dotted Map keys as single bracket key', () => {
expect(
flattenedKeyToFilterKey(
'ResourceAttributes.service.instance.id',
traceColumnMeta,
),
).toBe("ResourceAttributes['service.instance.id']");
});
it('escapes single quotes in Map keys', () => {
expect(
flattenedKeyToFilterKey("ResourceAttributes.it's.key", traceColumnMeta),
).toBe("ResourceAttributes['it''s.key']");
});
it('returns simple columns unchanged', () => {
expect(flattenedKeyToFilterKey('TraceId', traceColumnMeta)).toBe('TraceId');
});
it('returns simple columns unchanged for non-Map types', () => {
expect(flattenedKeyToFilterKey('Timestamp', traceColumnMeta)).toBe(
'Timestamp',
);
});
});

View file

@ -163,6 +163,83 @@ export function applyTopNAggregation(
];
}
// ---------------------------------------------------------------------------
// Filter key conversion helpers
// ---------------------------------------------------------------------------
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Converts a flattened dot-notation property key (produced by flattenData())
* into a valid ClickHouse SQL expression for use in filter conditions.
*
* flattenData() uses JavaScript's object/array iteration, producing keys like:
* "ResourceAttributes.service.name" for Map(String, String) columns
* "Events.Attributes[0].message.type" for Array(Map(String, String)) columns
*
* These must be converted to bracket notation for ClickHouse Map access:
* "ResourceAttributes['service.name']"
* "Events.Attributes[1]['message.type']" (note: 0-based JS -> 1-based CH index)
*/
export function flattenedKeyToSqlExpression(
key: string,
columnMeta: { name: string; type: string }[],
): string {
for (const col of columnMeta) {
const baseType = stripTypeWrappers(col.type);
if (baseType.startsWith('Map(')) {
if (key.startsWith(col.name + '.')) {
const mapKey = key.slice(col.name.length + 1).replace(/'/g, "''");
return `${col.name}['${mapKey}']`;
}
} else if (baseType.startsWith('Array(')) {
const innerType = stripTypeWrappers(baseType.slice('Array('.length, -1));
if (innerType.startsWith('Map(')) {
const pattern = new RegExp(
`^${escapeRegExp(col.name)}\\[(\\d+)\\]\\.(.+)$`,
);
const match = key.match(pattern);
if (match) {
const chIndex = parseInt(match[1], 10) + 1;
const mapKey = match[2].replace(/'/g, "''");
return `${col.name}[${chIndex}]['${mapKey}']`;
}
}
}
}
return key;
}
/**
* Converts a flattened dot-notation property key into a filter key using
* ClickHouse bracket notation for Map columns.
* This matches the search bar format (WHERE ResourceAttributes['k8s.pod.name'] = ...).
* For simple (non-Map) columns, returns the key unchanged.
*
* NOTE: Currently produces the same output as flattenedKeyToSqlExpression for
* Map columns. Kept separate because filter keys may diverge in the future
* (e.g., sidebar facet format vs SQL WHERE clause format for Array(Map) columns).
*/
export function flattenedKeyToFilterKey(
key: string,
columnMeta: { name: string; type: string }[],
): string {
// Delegates to flattenedKeyToSqlExpression for now — both produce bracket
// notation for Map columns. Kept as a separate entry point so filter keys
// can diverge from SQL expressions in the future (e.g., different format
// for sidebar facets vs WHERE clause for Array(Map) columns).
return flattenedKeyToSqlExpression(key, columnMeta);
}
export type AddFilterFn = (
property: string,
value: string,
action?: 'only' | 'exclude' | 'include',
) => void;
// ---------------------------------------------------------------------------
// Field classification helpers
// ---------------------------------------------------------------------------