mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
Fix heatmap delta query config inheritance
Co-authored-by: Alex Fedotyev <alex-fedotyev@users.noreply.github.com>
This commit is contained in:
parent
8e75a0a07d
commit
07de4476ac
3 changed files with 180 additions and 5 deletions
|
|
@ -23,9 +23,13 @@ jest.mock('@/components/DBNumberChart', () => ({
|
|||
default: () => <div data-testid="db-number-chart">Number Chart</div>,
|
||||
}));
|
||||
|
||||
const mockDBHeatmapWithDeltasChart = jest.fn(() => (
|
||||
<div data-testid="db-heatmap-with-deltas">Heatmap Chart</div>
|
||||
));
|
||||
|
||||
jest.mock('@/components/DBHeatmapWithDeltasChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="db-heatmap-with-deltas">Heatmap Chart</div>,
|
||||
default: (props: unknown) => mockDBHeatmapWithDeltasChart(props),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/DBPieChart', () => ({
|
||||
|
|
@ -88,6 +92,7 @@ const renderPanel = (
|
|||
describe('ChartPreviewPanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDBHeatmapWithDeltasChart.mockClear();
|
||||
});
|
||||
|
||||
describe('when no query has been run', () => {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,74 @@ import DBHeatmapChart, {
|
|||
lightPalette,
|
||||
} from './DBHeatmapChart';
|
||||
|
||||
function stripTrailingAlias(expression: string): string {
|
||||
const normalized = expression.trim();
|
||||
if (!normalized) return normalized;
|
||||
|
||||
let parenDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inBacktick = false;
|
||||
let aliasStartIndex: number | undefined;
|
||||
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
const char = normalized[i];
|
||||
const prev = i > 0 ? normalized[i - 1] : '';
|
||||
|
||||
if (char === "'" && !inDoubleQuote && !inBacktick && prev !== '\\') {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' && !inSingleQuote && !inBacktick && prev !== '\\') {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
continue;
|
||||
}
|
||||
if (char === '`' && !inSingleQuote && !inDoubleQuote && prev !== '\\') {
|
||||
inBacktick = !inBacktick;
|
||||
continue;
|
||||
}
|
||||
if (inSingleQuote || inDoubleQuote || inBacktick) continue;
|
||||
|
||||
if (char === '(') {
|
||||
parenDepth++;
|
||||
continue;
|
||||
}
|
||||
if (char === ')') {
|
||||
parenDepth--;
|
||||
continue;
|
||||
}
|
||||
if (char === '[') {
|
||||
bracketDepth++;
|
||||
continue;
|
||||
}
|
||||
if (char === ']') {
|
||||
bracketDepth--;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parenDepth === 0 && bracketDepth === 0 && /\s/.test(char)) {
|
||||
let j = i;
|
||||
while (j < normalized.length && /\s/.test(normalized[j])) j++;
|
||||
if (
|
||||
normalized.slice(j, j + 2).toUpperCase() === 'AS' &&
|
||||
j + 2 < normalized.length &&
|
||||
/\s/.test(normalized[j + 2])
|
||||
) {
|
||||
aliasStartIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aliasStartIndex == null
|
||||
? normalized
|
||||
: normalized.slice(0, aliasStartIndex).trim();
|
||||
}
|
||||
|
||||
function sanitizeTimestampExpression(timestampValueExpression: string): string {
|
||||
return stripTrailingAlias(timestampValueExpression);
|
||||
}
|
||||
|
||||
const Schema = z.object({
|
||||
value: z.string().trim().min(1),
|
||||
count: z.string().trim().optional(),
|
||||
|
|
@ -155,6 +223,24 @@ export default function DBHeatmapWithDeltasChart({
|
|||
const showHeatmapSetupHint = !resolvedValueExpression.trim();
|
||||
const spanIdExpression =
|
||||
'spanIdExpression' in source ? source.spanIdExpression : undefined;
|
||||
const deltaChartConfig = useMemo(() => {
|
||||
// DBDeltaChart builds ad-hoc grouped/ordered queries from timestampValueExpression.
|
||||
// If the expression carries a trailing alias (e.g. "toStartOfInterval(...) AS __hdx_time_bucket"),
|
||||
// ClickHouse can fail in generated GROUP BY clauses. Use alias-free timestamp expressions there.
|
||||
const sanitizedTimestampValueExpression = sanitizeTimestampExpression(
|
||||
chartConfig.timestampValueExpression,
|
||||
);
|
||||
|
||||
return {
|
||||
...chartConfig,
|
||||
with: undefined, // Avoid colliding with DBDeltaChart's internal CTE names
|
||||
select: 'tuple(_part, _part_offset)', // Keep base config minimal for DBDeltaChart's custom SELECTs
|
||||
groupBy: undefined, // Prevent invalid "SELECT * ... GROUP BY" queries in delta queries
|
||||
having: undefined, // DBDeltaChart applies HAVING only for aggregate selection paths
|
||||
granularity: undefined, // Let DBDeltaChart control grouping via timestamp expression when needed
|
||||
timestampValueExpression: sanitizedTimestampValueExpression,
|
||||
};
|
||||
}, [chartConfig]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
|
|
@ -257,10 +343,7 @@ export default function DBHeatmapWithDeltasChart({
|
|||
</Flex>
|
||||
) : (
|
||||
<DBDeltaChart
|
||||
config={{
|
||||
...chartConfig,
|
||||
with: undefined,
|
||||
}}
|
||||
config={deltaChartConfig}
|
||||
valueExpr={resolvedValueExpression}
|
||||
xMin={resolvedSelection.xMin}
|
||||
xMax={resolvedSelection.xMax}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import React from 'react';
|
||||
import { SourceKind } from '@hyperdx/common-utils/dist/types';
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import DBHeatmapWithDeltasChart from '../DBHeatmapWithDeltasChart';
|
||||
|
||||
const mockDBDeltaChart = jest.fn(() => (
|
||||
<div data-testid="db-delta-chart">Delta chart</div>
|
||||
));
|
||||
const mockDBHeatmapChart = jest.fn(() => (
|
||||
<div data-testid="db-heatmap-chart">Heatmap chart</div>
|
||||
));
|
||||
|
||||
jest.mock('../DBDeltaChart', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown) => mockDBDeltaChart(props),
|
||||
}));
|
||||
|
||||
jest.mock('../DBHeatmapChart', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown) => mockDBHeatmapChart(props),
|
||||
ColorLegend: () => <div data-testid="color-legend" />,
|
||||
darkPalette: ['#000000'],
|
||||
lightPalette: ['#ffffff'],
|
||||
}));
|
||||
|
||||
jest.mock('@/components/SQLEditor/SQLInlineEditor', () => ({
|
||||
SQLInlineEditorControlled: () => (
|
||||
<div data-testid="sql-inline-editor-controlled">SQL editor</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const baseChartConfig = {
|
||||
timestampValueExpression:
|
||||
'toStartOfInterval(toDateTime(TimestampTime), INTERVAL 1 minute) AS `__hdx_time_bucket`',
|
||||
connection: 'default',
|
||||
from: { databaseName: 'default', tableName: 'otel_logs' },
|
||||
select: [{ aggFn: 'count' as const, valueExpression: '' }],
|
||||
where: '',
|
||||
granularity: 'auto' as const,
|
||||
dateRange: [
|
||||
new Date('2026-04-10T23:00:00Z'),
|
||||
new Date('2026-04-11T00:00:00Z'),
|
||||
],
|
||||
};
|
||||
|
||||
const mockTraceSource = {
|
||||
id: 'trace-source',
|
||||
kind: SourceKind.Trace,
|
||||
name: 'Demo Traces',
|
||||
connection: 'default',
|
||||
from: { databaseName: 'default', tableName: 'otel_traces' },
|
||||
timestampValueExpression: 'TimestampTime',
|
||||
durationExpression: 'Duration',
|
||||
durationPrecision: 9,
|
||||
spanIdExpression: 'SpanId',
|
||||
} as const;
|
||||
|
||||
describe('DBHeatmapWithDeltasChart', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sanitizes timestamp expression aliases before passing config to DBDeltaChart', () => {
|
||||
renderWithMantine(
|
||||
<DBHeatmapWithDeltasChart
|
||||
chartConfig={baseChartConfig}
|
||||
source={mockTraceSource}
|
||||
isReady
|
||||
valueExpression="Duration"
|
||||
countExpression="count()"
|
||||
scaleType="log"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('db-delta-chart')).toBeInTheDocument();
|
||||
expect(mockDBDeltaChart).toHaveBeenCalled();
|
||||
|
||||
const firstCallProps = mockDBDeltaChart.mock.calls[0]?.[0] as {
|
||||
config?: { timestampValueExpression?: string };
|
||||
};
|
||||
|
||||
expect(firstCallProps.config?.timestampValueExpression).toBe(
|
||||
'toStartOfInterval(toDateTime(TimestampTime), INTERVAL 1 minute)',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue