mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
ced90ae10e
commit
2efb8fdc52
7 changed files with 444 additions and 69 deletions
5
.changeset/filter-actions.md
Normal file
5
.changeset/filter-actions.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: filter/exclude/copy actions on Event Deltas attribute values
|
||||
|
|
@ -1873,6 +1873,7 @@ function DBSearchPage() {
|
|||
}}
|
||||
isReady={isReady}
|
||||
source={searchedSource}
|
||||
onAddFilter={searchFilters.setFilterValue}
|
||||
/>
|
||||
)}
|
||||
{analysisMode === 'results' && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue