From 2efb8fdc52b5b5a706df532b5025389634dba526 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:25:06 -0700 Subject: [PATCH] feat: filter/exclude/copy actions on attribute values (#1850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .changeset/filter-actions.md | 5 + packages/app/src/DBSearchPage.tsx | 1 + packages/app/src/components/DBDeltaChart.tsx | 20 +- .../components/PropertyComparisonChart.tsx | 248 +++++++++++++----- .../Search/DBSearchHeatmapChart.tsx | 20 +- .../__tests__/deltaChartFilterKeys.test.ts | 142 ++++++++++ .../app/src/components/deltaChartUtils.ts | 77 ++++++ 7 files changed, 444 insertions(+), 69 deletions(-) create mode 100644 .changeset/filter-actions.md create mode 100644 packages/app/src/components/__tests__/deltaChartFilterKeys.test.ts diff --git a/.changeset/filter-actions.md b/.changeset/filter-actions.md new file mode 100644 index 00000000..92d1b8c6 --- /dev/null +++ b/.changeset/filter-actions.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: filter/exclude/copy actions on Event Deltas attribute values diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index a2fe80e0..e41b4f68 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -1873,6 +1873,7 @@ function DBSearchPage() { }} isReady={isReady} source={searchedSource} + onAddFilter={searchFilters.setFilterValue} /> )} {analysisMode === 'results' && ( diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 9ff7f9f2..3eb80b34 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -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>( + (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} /> ))} diff --git a/packages/app/src/components/PropertyComparisonChart.tsx b/packages/app/src/components/PropertyComparisonChart.tsx index 5954d8f6..2cad6243 100644 --- a/packages/app/src/components/PropertyComparisonChart.tsx +++ b/packages/app/src/components/PropertyComparisonChart.tsx @@ -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 & { }; // 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 ( {value} @@ -117,10 +126,12 @@ export function PropertyComparisonChart({ name, outlierValueOccurences, inlierValueOccurences, + onAddFilter, }: { name: string; outlierValueOccurences: Map; inlierValueOccurences: Map; + onAddFilter?: AddFilterFn; }) { const mergedValueStatistics = mergeValueStatisticsMaps( outlierValueOccurences, @@ -128,72 +139,175 @@ export function PropertyComparisonChart({ ); const chartData = applyTopNAggregation(mergedValueStatistics); + const [clickedValue, setClickedValue] = useState(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 ( -
- - {name} - - - - } /> - - } - allowEscapeViewBox={{ x: true, y: true }} - wrapperStyle={{ zIndex: 1000 }} - /> - { + if (!opened) setClickedValue(null); + }} + position="top" + withArrow + shadow="md" + > + +
+ - {chartData.map((entry, index) => ( - + + + } /> + - ))} - - - {chartData.map((entry, index) => ( - } + allowEscapeViewBox={{ x: false, y: true }} + wrapperStyle={{ zIndex: 1000 }} /> - ))} - - - -
+ + {chartData.map((entry, index) => ( + + ))} + + + {chartData.map((entry, index) => ( + + ))} + +
+
+
+ + + {clickedValue !== null && ( + <> + + {truncateMiddle(name, 40)} + + + {clickedValue.length === 0 ? Empty String : clickedValue} + + + + Selection:{' '} + {(outlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}% + + + Background:{' '} + {(inlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}% + + + + {onAddFilter && ( + <> + { + onAddFilter(name, clickedValue, 'include'); + setClickedValue(null); + }} + > + + + { + onAddFilter(name, clickedValue, 'exclude'); + setClickedValue(null); + }} + > + + + + )} + clipboard.copy(clickedValue)} + > + + + + + )} + + ); } diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index e027199d..14b83050 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -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(null); + // After applying a filter, clear the heatmap selection so the delta chart + // resets instead of staying in comparison mode. + const handleAddFilterAndClearSelection = useCallback< + NonNullable + >( + (property, value, action) => { + setFields({ xMin: null, xMax: null, yMin: null, yMax: null }); + onAddFilter?.(property, value, action); + }, + [onAddFilter, setFields], + ); + return ( ) : ( diff --git a/packages/app/src/components/__tests__/deltaChartFilterKeys.test.ts b/packages/app/src/components/__tests__/deltaChartFilterKeys.test.ts new file mode 100644 index 00000000..740d00a1 --- /dev/null +++ b/packages/app/src/components/__tests__/deltaChartFilterKeys.test.ts @@ -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', + ); + }); +}); diff --git a/packages/app/src/components/deltaChartUtils.ts b/packages/app/src/components/deltaChartUtils.ts index 1fe08c79..90e8fc8b 100644 --- a/packages/app/src/components/deltaChartUtils.ts +++ b/packages/app/src/components/deltaChartUtils.ts @@ -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 // ---------------------------------------------------------------------------