Compare commits

..

1 Commits

Author SHA1 Message Date
Piyush Singariya
65252943a8 chore: bridging field evolution with promoted 2026-05-26 16:09:06 +05:30
21 changed files with 404 additions and 2031 deletions

View File

@@ -1,282 +0,0 @@
import { Widgets } from 'types/api/dashboard/getAll';
import {
MetricRangePayloadProps,
MetricRangePayloadV3,
} from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from '../../types';
import { prepareBarPanelConfig } from '../utils';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
() => ({
getStoredSeriesVisibility: jest.fn(),
}),
);
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
__esModule: true,
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
getLegend: jest.fn(
(_queryData: unknown, _query: unknown, labelName: string) =>
`legend-${labelName}`,
),
}));
jest.mock('lib/getLabelName', () => ({
__esModule: true,
default: jest.fn(
(_metric: unknown, _queryName: string, _legend: string) => 'baseLabel',
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({
getInitialStackedBands: jest.fn().mockReturnValue([]),
}),
);
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
.getLegend as jest.Mock;
const getLabelNameMock = jest.requireMock('lib/getLabelName')
.default as jest.Mock;
const getInitialStackedBandsMock = jest.requireMock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
).getInitialStackedBands as jest.Mock;
const createApiResponse = (
result: MetricRangePayloadProps['data']['result'] = [],
): MetricRangePayloadProps => ({
data: {
result,
resultType: 'matrix',
newResult: null as unknown as MetricRangePayloadV3,
},
});
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
({
id: 'widget-1',
yAxisUnit: 'ms',
isLogScale: false,
thresholds: [],
customLegendColors: {},
...overrides,
}) as Widgets;
const defaultTimezone = {
name: 'UTC',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
};
describe('BarPanel utils', () => {
beforeEach(() => {
jest.clearAllMocks();
getLabelNameMock.mockReturnValue('baseLabel');
getLegendMock.mockImplementation(
(_queryData: unknown, _query: unknown, labelName: string) =>
`legend-${labelName}`,
);
});
describe('prepareBarPanelData', () => {
it('returns aligned data with timestamps and empty series when result is empty', () => {
const data = prepareChartData(createApiResponse([]));
expect(data).toHaveLength(1);
expect(data[0]).toStrictEqual([]);
});
it('returns timestamps and one series of y values for single series', () => {
const data = prepareChartData(
createApiResponse([
{
metric: {},
queryName: 'Q',
legend: 'Series A',
values: [
[1000, '10'],
[2000, '20'],
],
} as MetricRangePayloadProps['data']['result'][0],
]),
);
expect(data).toHaveLength(2);
expect(data[0]).toStrictEqual([1000, 2000]);
expect(data[1]).toStrictEqual([10, 20]);
});
it('merges timestamps and fills missing values with null for multiple series', () => {
const data = prepareChartData(
createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[3000, '3'],
],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [
[1000, '10'],
[2000, '20'],
],
} as MetricRangePayloadProps['data']['result'][0],
]),
);
expect(data[0]).toStrictEqual([1000, 2000, 3000]);
expect(data[1]).toStrictEqual([1, null, 3]);
expect(data[2]).toStrictEqual([10, 20, null]);
});
});
describe('prepareBarPanelConfig', () => {
const baseParams = {
widget: createWidget(),
isDarkMode: true,
currentQuery: {} as Query,
onClick: jest.fn(),
onDragSelect: jest.fn(),
apiResponse: createApiResponse(),
timezone: defaultTimezone,
panelMode: PanelMode.DASHBOARD_VIEW,
};
it('adds no series when apiResponse has empty result', () => {
const config = prepareBarPanelConfig(baseParams).getConfig();
expect(config.series).toHaveLength(1);
});
it('adds one series per result item', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
apiResponse,
}).getConfig();
expect(config.series).toHaveLength(3);
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
legend: 'L1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
apiResponse,
currentQuery: {} as Query,
}).getConfig();
expect(getLegendMock).toHaveBeenCalled();
expect(config.series?.[1]).toMatchObject({ label: 'legend-baseLabel' });
});
it('uses getLabelName for label when currentQuery is null', () => {
getLegendMock.mockReset();
const apiResponse = createApiResponse([
{
metric: { __name__: 'requests' },
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({
...baseParams,
apiResponse,
currentQuery: null as unknown as Query,
});
expect(getLabelNameMock).toHaveBeenCalled();
expect(getLegendMock).not.toHaveBeenCalled();
});
it('passes result metric to each series for cross-panel sync', () => {
const metric = { host: 'server1', __name__: 'http_requests' };
const apiResponse = createApiResponse([
{
metric,
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
apiResponse,
}).getConfig();
expect(config.series?.[1]).toMatchObject({ metric });
});
it('uses widget customLegendColors for series stroke', () => {
const widget = createWidget({
customLegendColors: { 'legend-baseLabel': '#ff0000' },
});
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
widget,
apiResponse,
}).getConfig();
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
});
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
const widget = createWidget({ stackedBarChart: true });
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
// seriesCount = result.length + 1 = 3
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
});
it('does not call getInitialStackedBands for non-stacked chart', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, apiResponse });
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -303,27 +303,6 @@ describe('TimeSeriesPanel utils', () => {
expect(seriesConfig!.stroke).toBe('#ff0000');
});
it('passes result metric to each series for cross-panel sync', () => {
const metric = { host: 'server1', __name__: 'cpu' };
const apiResponse = createApiResponse([
{
metric,
queryName: 'Q',
values: [
[1000, '1'],
[2000, '2'],
],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareUPlotConfig({
...baseParams,
apiResponse,
}).getConfig();
expect(config.series?.[1]).toMatchObject({ metric });
});
it('adds multiple series when result has multiple items', () => {
const apiResponse = createApiResponse([
{

View File

@@ -1,296 +0,0 @@
import { renderHook } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from '../drilldownUtils';
import { AggregateData } from '../useAggregateDrilldown';
import useBaseDrilldownNavigate, {
buildDrilldownUrl,
getRoute,
} from '../useBaseDrilldownNavigate';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('../drilldownUtils', () => ({
...jest.requireActual('../drilldownUtils'),
getViewQuery: jest.fn(),
}));
const mockGetViewQuery = getViewQuery as jest.Mock;
// ─── Fixtures ────────────────────────────────────────────────────────────────
const MOCK_QUERY: Query = {
id: 'q1',
queryType: 'builder' as any,
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'metrics' as any,
groupBy: [],
expression: '',
disabled: false,
functions: [],
legend: '',
having: [],
limit: null,
stepInterval: undefined,
orderBy: [],
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
};
const MOCK_VIEW_QUERY: Query = {
...MOCK_QUERY,
builder: {
...MOCK_QUERY.builder,
queryData: [
{
...MOCK_QUERY.builder.queryData[0],
filters: { items: [], op: 'AND' },
},
],
},
};
const MOCK_AGGREGATE_DATA: AggregateData = {
queryName: 'A',
filters: [{ filterKey: 'service_name', filterValue: 'auth', operator: '=' }],
timeRange: { startTime: 1000000, endTime: 2000000 },
};
// ─── getRoute ─────────────────────────────────────────────────────────────────
describe('getRoute', () => {
it.each([
['view_logs', ROUTES.LOGS_EXPLORER],
['view_metrics', ROUTES.METRICS_EXPLORER],
['view_traces', ROUTES.TRACES_EXPLORER],
])('maps %s to the correct explorer route', (key, expected) => {
expect(getRoute(key)).toBe(expected);
});
it('returns empty string for an unknown key', () => {
expect(getRoute('view_dashboard')).toBe('');
});
});
// ─── buildDrilldownUrl ────────────────────────────────────────────────────────
describe('buildDrilldownUrl', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('returns null for an unknown drilldown key', () => {
const url = buildDrilldownUrl(
MOCK_QUERY,
MOCK_AGGREGATE_DATA,
'view_dashboard',
);
expect(url).toBeNull();
});
it('returns null when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toBeNull();
});
it('returns a URL starting with the logs explorer route for view_logs', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toBeNull();
expect(url).toContain(ROUTES.LOGS_EXPLORER);
});
it('returns a URL starting with the traces explorer route for view_traces', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_traces');
expect(url).toContain(ROUTES.TRACES_EXPLORER);
});
it('includes compositeQuery param in the URL', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('compositeQuery=');
});
it('includes startTime and endTime when aggregateData has a timeRange', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('startTime=1000000');
expect(url).toContain('endTime=2000000');
});
it('omits startTime and endTime when aggregateData has no timeRange', () => {
const { timeRange: _, ...withoutTimeRange } = MOCK_AGGREGATE_DATA;
const url = buildDrilldownUrl(MOCK_QUERY, withoutTimeRange, 'view_logs');
expect(url).not.toContain('startTime=');
expect(url).not.toContain('endTime=');
});
it('includes summaryFilters param for view_metrics', () => {
const url = buildDrilldownUrl(
MOCK_QUERY,
MOCK_AGGREGATE_DATA,
'view_metrics',
);
expect(url).toContain(ROUTES.METRICS_EXPLORER);
expect(url).toContain('summaryFilters=');
});
it('does not include summaryFilters param for non-metrics routes', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toContain('summaryFilters=');
});
it('handles null aggregateData by passing empty filters and empty queryName', () => {
const url = buildDrilldownUrl(MOCK_QUERY, null, 'view_logs');
expect(url).not.toBeNull();
expect(mockGetViewQuery).toHaveBeenCalledWith(
MOCK_QUERY,
[],
'view_logs',
'',
);
});
it('passes aggregateData filters and queryName to getViewQuery', () => {
buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(mockGetViewQuery).toHaveBeenCalledWith(
MOCK_QUERY,
MOCK_AGGREGATE_DATA.filters,
'view_logs',
MOCK_AGGREGATE_DATA.queryName,
);
});
});
// ─── useBaseDrilldownNavigate ─────────────────────────────────────────────────
describe('useBaseDrilldownNavigate', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls safeNavigate with the built URL on a valid key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_logs');
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain('compositeQuery=');
});
it('opens the explorer in a new tab', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_traces');
expect(mockSafeNavigate).toHaveBeenCalledWith(expect.any(String), {
newTab: true,
});
});
it('calls callback after successful navigation', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
});
it('does not call safeNavigate for an unknown key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_dashboard');
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when the key is unknown', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_dashboard');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('handles null aggregateData without throwing', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: null,
}),
);
expect(() => result.current('view_logs')).not.toThrow();
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
});
});

View File

@@ -168,7 +168,7 @@ export const getAggregateColumnHeader = (
};
};
export const getFiltersFromMetric = (metric: any): FilterData[] =>
const getFiltersFromMetric = (metric: any): FilterData[] =>
Object.keys(metric).map((key) => ({
filterKey: key,
filterValue: metric[key],

View File

@@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Link, Loader } from '@signozhq/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
@@ -14,10 +18,9 @@ import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
import { getAggregateColumnHeader } from './drilldownUtils';
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
import { getBaseContextConfig } from './menuOptions';
import { AggregateData } from './useAggregateDrilldown';
import useBaseDrilldownNavigate from './useBaseDrilldownNavigate';
interface UseBaseAggregateOptionsProps {
query: Query;
@@ -35,6 +38,19 @@ interface BaseAggregateOptionsConfig {
items?: ContextMenuItem;
}
const getRoute = (key: string): string => {
switch (key) {
case 'view_logs':
return ROUTES.LOGS_EXPLORER;
case 'view_metrics':
return ROUTES.METRICS_EXPLORER;
case 'view_traces':
return ROUTES.TRACES_EXPLORER;
default:
return '';
}
};
const useBaseAggregateOptions = ({
query,
onClose,
@@ -70,6 +86,8 @@ const useBaseAggregateOptions = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);
const { safeNavigate } = useSafeNavigate();
// Use the new useContextVariables hook
const { processedVariables } = useContextVariables({
maxValues: 2,
@@ -103,16 +121,50 @@ const useBaseAggregateOptions = ({
{label}
</ContextMenu.Item>
));
} catch {
} catch (error) {
return [];
}
}, [contextLinks, processedVariables, onClose, aggregateData, query]);
const handleBaseDrilldown = useBaseDrilldownNavigate({
resolvedQuery,
aggregateData,
callback: onClose,
});
const handleBaseDrilldown = useCallback(
(key: string): void => {
const route = getRoute(key);
const timeRange = aggregateData?.timeRange;
const filtersToAdd = aggregateData?.filters || [];
const viewQuery = getViewQuery(
resolvedQuery,
filtersToAdd,
key,
aggregateData?.queryName || '',
);
let queryParams = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange?.startTime.toString(),
[QueryParams.endTime]: timeRange?.endTime.toString(),
}),
} as Record<string, string>;
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery?.builder.queryData[0].filters,
),
};
}
if (route) {
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
}
onClose();
},
[resolvedQuery, safeNavigate, onClose, aggregateData],
);
const { pathname } = useLocation();

View File

@@ -1,117 +0,0 @@
import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from './drilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
type DrilldownKey = 'view_logs' | 'view_metrics' | 'view_traces';
const DRILLDOWN_ROUTE_MAP: Record<DrilldownKey, string> = {
view_logs: ROUTES.LOGS_EXPLORER,
view_metrics: ROUTES.METRICS_EXPLORER,
view_traces: ROUTES.TRACES_EXPLORER,
};
const getRoute = (key: string): string =>
DRILLDOWN_ROUTE_MAP[key as DrilldownKey] ?? '';
interface UseBaseDrilldownNavigateProps {
resolvedQuery: Query;
aggregateData: AggregateData | null;
callback?: () => void;
}
const useBaseDrilldownNavigate = ({
resolvedQuery,
aggregateData,
callback,
}: UseBaseDrilldownNavigateProps): ((key: string) => void) => {
const { safeNavigate } = useSafeNavigate();
return useCallback(
(key: string): void => {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
callback?.();
return;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
callback?.();
},
[resolvedQuery, safeNavigate, callback, aggregateData],
);
};
export function buildDrilldownUrl(
resolvedQuery: Query,
aggregateData: AggregateData | null,
key: string,
): string | null {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
return null;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
return `${route}?${createQueryParams(queryParams)}`;
}
export { getRoute };
export default useBaseDrilldownNavigate;

View File

@@ -1,29 +0,0 @@
import { syncCursorRegistry } from '../syncCursorRegistry';
describe('syncCursorRegistry', () => {
describe('metadata', () => {
it('returns undefined for unknown key', () => {
expect(syncCursorRegistry.getMetadata('unknown-meta')).toBeUndefined();
});
it('stores and retrieves metadata by syncKey', () => {
const metadata = { yAxisUnit: 'ms', groupBy: [] };
syncCursorRegistry.setMetadata('meta-key', metadata);
expect(syncCursorRegistry.getMetadata('meta-key')).toBe(metadata);
});
});
describe('activeSeriesMetric', () => {
it('returns null (not undefined) for unknown key', () => {
expect(
syncCursorRegistry.getActiveSeriesMetric('unknown-metric'),
).toBeNull();
});
it('stores and retrieves metric by syncKey', () => {
const metric = { host: 'server1', __name__: 'cpu' };
syncCursorRegistry.setActiveSeriesMetric('metric-key', metric);
expect(syncCursorRegistry.getActiveSeriesMetric('metric-key')).toBe(metric);
});
});
});

View File

@@ -1,627 +0,0 @@
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import uPlot from 'uplot';
import { syncCursorRegistry } from '../syncCursorRegistry';
import { createSyncDisplayHook } from '../syncDisplayHook';
import {
SyncTooltipFilterMode,
type TooltipControllerState,
type TooltipSyncMetadata,
} from '../types';
jest.mock('../syncCursorRegistry', () => ({
syncCursorRegistry: {
setMetadata: jest.fn(),
getMetadata: jest.fn(),
setActiveSeriesMetric: jest.fn(),
getActiveSeriesMetric: jest.fn(),
},
}));
const mockRegistry = syncCursorRegistry as {
setMetadata: jest.Mock;
getMetadata: jest.Mock;
setActiveSeriesMetric: jest.Mock;
getActiveSeriesMetric: jest.Mock;
};
const SYNC_KEY = 'test-sync-key';
// Builds a single-query groupByPerQuery from a list of dimension keys.
const makeGroupByPerQuery = (
...keys: string[]
): Record<string, BaseAutocompleteData[]> => ({
A: keys.map((key) => ({ key, type: 'tag' as const })),
});
function makeUPlotRoot(includeCrosshair = true): HTMLElement {
const root = document.createElement('div');
if (includeCrosshair) {
const el = document.createElement('div');
el.className = 'u-cursor-y';
root.append(el);
}
return root;
}
type FakeSeries = { metric?: Record<string, string>; show?: boolean };
function makeFakeUPlot(opts: {
cursorEvent?: MouseEvent | null;
cursorLeft?: number;
series?: FakeSeries[];
includeCrosshair?: boolean;
}): uPlot {
return {
root: makeUPlotRoot(opts.includeCrosshair ?? true),
cursor: {
event: opts.cursorEvent !== undefined ? opts.cursorEvent : null,
left: opts.cursorLeft ?? 50,
},
series: opts.series ?? [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
],
setSeries: jest.fn(),
} as unknown as uPlot;
}
function makeController(
focusedSeriesIndex: number | null = null,
): TooltipControllerState {
return {
focusedSeriesIndex,
syncedSeriesIndexes: null,
} as TooltipControllerState;
}
// Convenience cast used throughout assertions.
function mockSetSeries(u: uPlot): jest.Mock {
return (u as unknown as { setSeries: jest.Mock }).setSeries;
}
function getCrosshair(u: uPlot): HTMLElement {
const el = u.root.querySelector<HTMLElement>('.u-cursor-y');
if (!el) {
throw new Error('crosshair element missing');
}
return el;
}
// ─────────────────────────────────────────────────────────────────────────────
describe('createSyncDisplayHook', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// ── guard ────────────────────────────────────────────────────────────────
describe('no crosshair element', () => {
it('returns early without calling registry when .u-cursor-y absent', () => {
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
const u = makeFakeUPlot({ includeCrosshair: false });
hook(u);
expect(mockRegistry.setMetadata).not.toHaveBeenCalled();
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
expect(mockSetSeries(u)).not.toHaveBeenCalled();
});
});
// ── source panel ─────────────────────────────────────────────────────────
describe('source behavior (cursor.event != null)', () => {
it('writes syncMetadata to registry', () => {
const syncMetadata: TooltipSyncMetadata = { yAxisUnit: 'ms' };
const hook = createSyncDisplayHook(SYNC_KEY, syncMetadata, makeController());
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(mockRegistry.setMetadata).toHaveBeenCalledWith(
SYNC_KEY,
syncMetadata,
);
});
it('writes focused series metric when focusedSeriesIndex is set', () => {
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
];
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController(1));
const u = makeFakeUPlot({
cursorEvent: new MouseEvent('mousemove'),
series,
});
hook(u);
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(SYNC_KEY, {
host: 'server1',
});
});
it('writes null metric when focusedSeriesIndex is null', () => {
const hook = createSyncDisplayHook(
SYNC_KEY,
undefined,
makeController(null),
);
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(
SYNC_KEY,
null,
);
});
it('clears controller.syncedSeriesIndexes', () => {
const controller = makeController();
controller.syncedSeriesIndexes = [1, 2];
const hook = createSyncDisplayHook(SYNC_KEY, undefined, controller);
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(controller.syncedSeriesIndexes).toBeNull();
});
it('shows crosshair and does not read from registry', () => {
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(getCrosshair(u).style.display).toBe('');
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
});
});
// ── receiver panel ───────────────────────────────────────────────────────
describe('receiver behavior (cursor.event is null)', () => {
describe('crosshair visibility', () => {
it('shows crosshair when yAxisUnit matches source', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms' },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(getCrosshair(u).style.display).toBe('');
});
it('hides crosshair when yAxisUnit differs from source', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'bytes' });
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms' },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(getCrosshair(u).style.display).toBe('none');
});
});
// ── exact groupBy match ───────────────────────────────────────────────
describe('exact groupBy match', () => {
const groupByPerQuery = makeGroupByPerQuery('host');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
];
it('focuses the matching series and records it on the controller', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server2' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
});
it('unfocuses all and emits empty matches (Filtered) when active metric is null', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('unfocuses all when metric matches no series', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
host: 'unknown-server',
});
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('clears syncedSeriesIndexes when cursor is off-plot (left < 0)', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: -1, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toBeNull();
expect(mockRegistry.getActiveSeriesMetric).not.toHaveBeenCalled();
});
it('never focuses series at index 0 (x-axis)', () => {
const sameMetric = { host: 'server1' };
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue(sameMetric);
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({
cursorEvent: null,
cursorLeft: 50,
// Index 0 has the same metric — it must always be skipped.
series: [{ metric: sameMetric }, { metric: { host: 'other' } }],
});
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('skips hidden series (show === false)', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({
cursorEvent: null,
cursorLeft: 50,
series: [
{},
{ metric: { host: 'server1' }, show: false },
{ metric: { host: 'server1' } },
],
});
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
});
});
// ── partial groupBy overlap ───────────────────────────────────────────
describe('partial groupBy overlap', () => {
it('subset — records every receiver series matching on the common key', () => {
// Source groupBy=[host], receiver groupBy=[host, service].
// Hook focuses the first match; the rest are surfaced via controller.syncedSeriesIndexes.
const sourceGroupBy = makeGroupByPerQuery('host');
const receiverGroupBy = makeGroupByPerQuery('host', 'service');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1', service: 'api' } },
{ metric: { host: 'server1', service: 'frontend' } },
{ metric: { host: 'server2', service: 'api' } },
];
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
});
it('superset — records the one receiver series matching on the common key', () => {
// Source groupBy=[host, service], receiver groupBy=[host].
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
const receiverGroupBy = makeGroupByPerQuery('host');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
];
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
host: 'server1',
service: 'api',
});
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
});
it('partial — matches on the intersecting key only', () => {
// Source groupBy=[host, service], receiver groupBy=[service, region].
// Common key is [service]. Both receiver series with service=api match.
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
const receiverGroupBy = makeGroupByPerQuery('service', 'region');
const series: FakeSeries[] = [
{},
{ metric: { service: 'api', region: 'us-east' } },
{ metric: { service: 'api', region: 'eu-west' } },
{ metric: { service: 'frontend', region: 'us-east' } },
];
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
host: 'server1',
service: 'api',
});
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
});
});
// ── union across queries in groupByPerQuery ───────────────────────────
describe('union across queries', () => {
it("treats the panel's effective groupBy as the union across its queries", () => {
// Source has query A=[host]; receiver has A=[host], B=[service].
// The shared key is `host` — receiver matches on that.
const sourceGroupBy: Record<string, BaseAutocompleteData[]> = {
A: [{ key: 'host', type: 'tag' }],
};
const receiverGroupBy: Record<string, BaseAutocompleteData[]> = {
A: [{ key: 'host', type: 'tag' }],
B: [{ key: 'service', type: 'tag' }],
};
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({
cursorEvent: null,
cursorLeft: 50,
series: [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
],
});
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
});
});
// ── no overlap (Filtered mode default) ────────────────────────────────
describe('no overlap → Filtered mode emits []', () => {
it('emits [] when groupBy keys are completely different', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('host'),
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('service') },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('emits [] when receiver groupBy is empty', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('host'),
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: {} },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('emits [] when source groupBy is absent', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('host') },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
});
// ── filterMode: All ──────────────────────────────────────────────────
describe('filterMode All', () => {
it('emits null (no filter) when there is no overlap in groupBy', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('host'),
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('service'),
filterMode: SyncTooltipFilterMode.All,
},
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toBeNull();
});
it('emits null when metric matches no series', () => {
const groupByPerQuery = makeGroupByPerQuery('host');
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'unknown' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{
yAxisUnit: 'ms',
groupByPerQuery,
filterMode: SyncTooltipFilterMode.All,
},
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50 });
hook(u);
expect(controller.syncedSeriesIndexes).toBeNull();
});
});
// ── caching ───────────────────────────────────────────────────────────
describe('caching optimizations', () => {
it('reuses the crosshair element across multiple invocations', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms' },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null });
const spy = jest.spyOn(u.root, 'querySelector');
hook(u);
hook(u);
hook(u);
// querySelector should only be called once regardless of invocation count.
expect(spy).toHaveBeenCalledTimes(1);
});
it('recomputes common keys when source groupByPerQuery reference changes', () => {
const hostGroupBy = makeGroupByPerQuery('host');
const serviceGroupBy = makeGroupByPerQuery('service');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1', service: 'api' } },
{ metric: { host: 'server2', service: 'frontend' } },
];
const hook = createSyncDisplayHook(
SYNC_KEY,
{ groupByPerQuery: makeGroupByPerQuery('host', 'service') },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
// First call: source groups by host → matches series 1.
mockRegistry.getMetadata.mockReturnValue({
groupByPerQuery: hostGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
jest.clearAllMocks();
// Second call: source now groups by service → matches series 2.
mockRegistry.getMetadata.mockReturnValue({
groupByPerQuery: serviceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
service: 'frontend',
});
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
});
});
});
});

View File

@@ -8,7 +8,6 @@
align-items: center;
padding: 8px 16px;
gap: 8px;
min-height: 52px;
// KeyValueLabel renders with a global `.key-value-label` root; keep it from
// shrinking on the trace details header.
@@ -21,28 +20,6 @@
flex-shrink: 0;
}
.traceIdSection {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.filterSection {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-left: auto;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.filter {
min-width: 0;
}
@@ -52,6 +29,15 @@
flex: 1;
}
.oldViewBtn {
flex-shrink: 0;
}
.analyticsBtn {
flex-shrink: 0;
margin-left: auto;
}
.subHeader {
display: flex;
align-items: center;

View File

@@ -21,7 +21,6 @@ import {
ArrowLeft,
CalendarClock,
ChartPie,
CornerUpLeft,
Server,
Timer,
} from '@signozhq/icons';
@@ -118,7 +117,7 @@ function TraceDetailsHeader({
<div className={styles.wrapper}>
<div className={styles.header}>
{!isFilterExpanded && (
<div className={styles.traceIdSection}>
<>
<Button
variant="solid"
color="secondary"
@@ -134,39 +133,20 @@ function TraceDetailsHeader({
badgeValue={traceID || ''}
maxCharacters={100}
/>
</div>
</>
)}
{isDataLoaded && (
<div
className={cx(
styles.filterSection,
isFilterExpanded && styles.isExpanded,
)}
>
<>
{!isFilterExpanded && (
<TooltipProvider>
<div className={styles.headerActions}>
<>
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Switch to legacy trace view"
onClick={handleSwitchToOldView}
>
<CornerUpLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Switch to legacy trace view</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Analytics"
className={styles.analyticsBtn}
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
<ChartPie size={14} />
@@ -174,18 +154,15 @@ function TraceDetailsHeader({
</TooltipTrigger>
<TooltipContent>Analytics</TooltipContent>
</TooltipRoot>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
/>
</div>
</TooltipProvider>
</TooltipProvider>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
/>
</>
)}
<div
key="filter"
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
>
<div className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
@@ -196,7 +173,18 @@ function TraceDetailsHeader({
onCollapse={(): void => setIsFilterExpanded(false)}
/>
</div>
</div>
{!isFilterExpanded && (
<Button
variant="solid"
color="secondary"
size="sm"
className={styles.oldViewBtn}
onClick={handleSwitchToOldView}
>
Legacy View
</Button>
)}
</>
)}
</div>

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { Settings2 } from '@signozhq/icons';
import { Ellipsis } from '@signozhq/icons';
import { useTraceStore } from '../stores/traceStore';
@@ -93,8 +93,7 @@ function TraceOptionsMenu({
variant="ghost"
size="icon"
color="secondary"
aria-label="Trace options"
prefix={<Settings2 size={14} />}
prefix={<Ellipsis size={14} />}
/>
</Dropdown>
);

View File

@@ -6,7 +6,6 @@ import TraceDetailsHeader from '../TraceDetailsHeader';
const mockGoBack = jest.fn();
const mockPush = jest.fn();
const mockReplace = jest.fn();
const mockHasInAppHistory = jest.fn();
jest.mock('lib/history', () => ({
@@ -14,47 +13,13 @@ jest.mock('lib/history', () => ({
default: {
goBack: (): void => mockGoBack(),
push: (path: string): void => mockPush(path),
replace: (path: string): void => mockReplace(path),
replace: jest.fn(),
location: { pathname: '/', search: '' },
listen: (): (() => void) => (): void => undefined,
},
hasInAppHistory: (): boolean => mockHasInAppHistory(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): { id: string } => ({ id: 'trace-123' }),
}));
const mockSetLocalStorageKey = jest.fn();
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void =>
mockSetLocalStorageKey(key, value),
}));
jest.mock(
'../../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters',
() => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="filters-stub" />,
}),
);
jest.mock('../../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel', () => ({
__esModule: true,
default: ({ isOpen }: { isOpen: boolean }): JSX.Element => (
<div data-testid="analytics-panel" data-open={isOpen ? 'true' : 'false'} />
),
}));
jest.mock('components/FieldsSelector', () => ({
__esModule: true,
default: ({ isOpen }: { isOpen: boolean }): JSX.Element => (
<div data-testid="fields-selector" data-open={isOpen ? 'true' : 'false'} />
),
}));
const baseProps = {
filterMetadata: {
startTime: 0,
@@ -93,70 +58,3 @@ describe('TraceDetailsHeader back button', () => {
expect(mockGoBack).not.toHaveBeenCalled();
});
});
describe('TraceDetailsHeader action cluster', () => {
beforeEach(() => {
mockReplace.mockClear();
mockSetLocalStorageKey.mockClear();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
expect(
screen.queryByRole('button', { name: /switch to legacy trace view/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /trace options/i }),
).not.toBeInTheDocument();
});
it('renders Legacy View, Analytics, and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /^analytics$/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /trace options/i }),
).toBeInTheDocument();
});
it('routes to the legacy trace view and persists the preference on click', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
fireEvent.click(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
);
expect(mockSetLocalStorageKey).toHaveBeenCalledWith(
'TRACE_DETAILS_PREFER_OLD_VIEW',
'true',
);
expect(mockReplace).toHaveBeenCalledTimes(1);
expect(mockReplace).toHaveBeenCalledWith(
expect.stringContaining('/trace-old/trace-123'),
);
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
const analyticsBtn = screen.getByRole('button', { name: /^analytics$/i });
fireEvent.click(analyticsBtn);
expect(panel).toHaveAttribute('data-open', 'true');
fireEvent.click(analyticsBtn);
expect(panel).toHaveAttribute('data-open', 'false');
});
});

View File

@@ -3,6 +3,12 @@
align-items: center;
gap: 12px;
// QuerySearch child sets `query-builder-search-v2` globally; size it to the
// search container by reaching into the descendant.
:global(.query-builder-search-v2) {
width: 100%;
}
// ToggleGroup children use generated class names; nest the global selectors
// under the local row so they only apply inside this filter row.
:global([class*='toggle-group']) {
@@ -14,43 +20,8 @@
}
}
// Expanded-mode root: grows to fill .filter wrapper, and lets the search
// input flex within. In collapsed mode none of these grow — the whole
// Filters region is content-sized (just the pill + result + toggle).
.isExpanded {
flex: 1;
.searchInput {
flex: 1;
}
.searchAndNav {
flex: 1;
}
}
.categoryControls {
display: flex;
align-items: center;
flex-shrink: 0;
}
.searchInput {
display: flex;
align-items: center;
min-width: 0;
}
.searchPill {
display: flex;
align-items: center;
flex-shrink: 0;
}
.searchAndNav {
display: flex;
align-items: center;
min-width: 0;
}
.searchContainer {
@@ -58,25 +29,6 @@
min-width: 0;
}
.resultActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.expandedActions {
display: flex;
align-items: center;
gap: 2px;
}
.highlightControl {
display: flex;
align-items: center;
flex-shrink: 0;
}
.pill {
display: flex;
align-items: center;
@@ -133,6 +85,14 @@
border-radius: 4px;
}
.collapseBtn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
.highlightErrorsToggle {
display: flex;
align-items: center;
@@ -140,3 +100,37 @@
flex-shrink: 0;
white-space: nowrap;
}
.preNextToggle {
display: flex;
flex-shrink: 0;
gap: 12px;
}
.preNextCount {
display: flex;
align-items: center;
margin: auto;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.filterStatus {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.hasError {
color: var(--destructive);
cursor: help;
}

View File

@@ -1,7 +1,15 @@
import { useCallback, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { ChevronsRight, Copy, Search, X } from '@signozhq/icons';
import {
ChevronDown,
ChevronUp,
Copy,
Info,
Loader,
Search,
X,
} from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { toast } from '@signozhq/ui/sonner';
@@ -13,6 +21,7 @@ import {
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
@@ -33,7 +42,6 @@ import {
SpanCategory,
useSpanCategoryFilter,
} from './hooks/useSpanCategoryFilter';
import QueryResult from './QueryResult';
import styles from './Filters.module.scss';
@@ -144,16 +152,6 @@ function Filters({
runQuery(expressionRef.current);
}, [runQuery]);
const handleClear = useCallback((): void => {
setExpression('');
expressionRef.current = '';
setFilters({ items: [], op: 'AND' });
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}, [onFilteredSpansChange]);
// Expression-based filter hooks
const filterProps = {
expression,
@@ -268,167 +266,164 @@ function Filters({
</div>
);
const hasExpression = expression.trim().length > 0;
const hasResults = filteredSpanIds.length > 0;
const handlePrev = useCallback((): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}, [currentSearchedIndex, handlePrevNext]);
const handleNext = useCallback((): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}, [currentSearchedIndex, handlePrevNext]);
const pill = (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div className={styles.pill} onClick={onExpand}>
<Search size={12} />
<span className={styles.pillText}>{expression || 'Search...'}</span>
{expression && <span className={styles.pillIndicator} />}
</div>
const statusIndicators = (
<>
{isFetching && <Loader className="animate-spin" />}
{error && (
<TooltipRoot>
<TooltipTrigger asChild>
<span className={cx(styles.filterStatus, styles.hasError)}>
<Info />
API error
</span>
</TooltipTrigger>
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</TooltipRoot>
)}
{!error && noData && (
<Typography.Text className={styles.filterStatus}>
No results found
</Typography.Text>
)}
</>
);
const pillWithPopover = expression ? (
<TooltipRoot>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className={styles.pillPopover}>
<div className={styles.pillPopoverHeader}>
<Typography.Text>Search query</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
>
<Copy size={12} />
</Button>
</div>
<div className={styles.pillPopoverExpression}>{expression}</div>
</div>
</TooltipContent>
</TooltipRoot>
) : (
pill
);
// --- COLLAPSED VIEW ---
if (!isExpanded) {
const pill = (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div className={styles.pill} onClick={onExpand}>
<Search size={12} />
<span className={styles.pillText}>{expression || 'Search...'}</span>
{expression && <span className={styles.pillIndicator} />}
</div>
);
// Mode-conditional render: only one of (pill | QuerySearch) is mounted
// at a time. Collapsing unmounts the editor — half-written queries are
// dropped, so collapse can't accidentally commit a malformed expression
// and fire an erroring /query_range request.
return (
<TooltipProvider>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={cx(styles.root, isExpanded && styles.isExpanded)}
ref={containerRef}
onBlur={(e): void => {
const relatedTarget = e.relatedTarget as Node | null;
const blurredIntoSelf = !!containerRef.current?.contains(relatedTarget);
if (!blurredIntoSelf) {
handleBlur();
}
}}
>
{isExpanded && (
<div className={styles.categoryControls}>
<ToggleGroup
type="single"
value={selectedCategory}
onChange={(value): void => {
if (value) {
handleCategoryChange(value as SpanCategory);
}
}}
size="sm"
>
{categories.map((category) => (
<ToggleGroupItem key={category} value={category}>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)}
<div className={styles.searchInput}>
{isExpanded ? (
<div className={styles.searchAndNav}>
<div className={styles.searchContainer}>
<QuerySearch
queryData={{
...BASE_FILTER_QUERY,
filters,
filter: { expression },
}}
onChange={handleExpressionChange}
onRun={handleRunQuery}
dataSource={DataSource.TRACES}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
/>
</div>
</div>
) : (
<div className={styles.searchPill}>{pillWithPopover}</div>
)}
</div>
<div className={styles.resultActions}>
<QueryResult
hasExpression={hasExpression}
hasResults={hasResults}
isFetching={isFetching}
error={error}
noData={noData}
currentIndex={currentSearchedIndex}
total={filteredSpanIds.length}
onPrev={handlePrev}
onNext={handleNext}
showNavigation={isExpanded}
/>
{isExpanded && (
<div className={styles.expandedActions}>
{hasExpression && (
<TooltipRoot>
<TooltipTrigger asChild>
return (
<TooltipProvider>
<div className={styles.root}>
{expression ? (
<TooltipRoot>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className={styles.pillPopover}>
<div className={styles.pillPopoverHeader}>
<Typography.Text>Search query</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleClear}
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
>
<X size={14} />
<Copy size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>Clear filter</TooltipContent>
</TooltipRoot>
)}
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onCollapse}
>
<ChevronsRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Collapse filters</TooltipContent>
</TooltipRoot>
</div>
</div>
<div className={styles.pillPopoverExpression}>{expression}</div>
</div>
</TooltipContent>
</TooltipRoot>
) : (
pill
)}
{highlightErrorsToggle}
{statusIndicators}
</div>
</TooltipProvider>
);
}
<div className={styles.highlightControl}>{highlightErrorsToggle}</div>
// --- EXPANDED VIEW ---
return (
<TooltipProvider>
<div className={cx(styles.root, styles.isExpanded)}>
<ToggleGroup
type="single"
value={selectedCategory}
onChange={(value): void => {
if (value) {
handleCategoryChange(value as SpanCategory);
}
}}
size="sm"
>
{categories.map((category) => (
<ToggleGroupItem key={category} value={category}>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={styles.searchContainer}
ref={containerRef}
onBlur={(e): void => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
handleBlur();
}
}}
>
<QuerySearch
queryData={{
...BASE_FILTER_QUERY,
filters,
filter: { expression },
}}
onChange={handleExpressionChange}
onRun={handleRunQuery}
dataSource={DataSource.TRACES}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
/>
</div>
{filteredSpanIds.length > 0 && (
<div className={styles.preNextToggle}>
<Typography.Text className={styles.preNextCount}>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentSearchedIndex === 0}
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
>
<ChevronDown size={14} />
</Button>
</div>
)}
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.collapseBtn}
onClick={onCollapse}
>
<X size={14} />
</Button>
{highlightErrorsToggle}
{statusIndicators}
</div>
</TooltipProvider>
);

View File

@@ -1,32 +0,0 @@
.resultNavCount {
padding: 0 6px;
white-space: nowrap;
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 12px;
}
.resultNavDivider {
width: 1px;
height: 14px;
background: var(--l3-border);
margin: 0 4px;
flex-shrink: 0;
}
.filterStatus {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.hasError {
color: var(--destructive);
cursor: help;
}

View File

@@ -1,111 +0,0 @@
import { ChevronDown, ChevronUp, Info, Loader } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import cx from 'classnames';
import styles from './QueryResult.module.scss';
type QueryResultProps = {
hasExpression: boolean;
hasResults: boolean;
isFetching: boolean;
error: unknown;
noData: boolean;
currentIndex: number;
total: number;
onPrev: () => void;
onNext: () => void;
showNavigation?: boolean;
};
function QueryResult({
hasExpression,
hasResults,
isFetching,
error,
noData,
currentIndex,
total,
onPrev,
onNext,
showNavigation = true,
}: QueryResultProps): JSX.Element | null {
if (!hasExpression) {
return null;
}
let content: JSX.Element | null = null;
if (hasResults && showNavigation) {
// Prefer count over loader on refresh so stale results stay visible.
content = (
<>
<Typography.Text className={styles.resultNavCount}>
{currentIndex + 1} / {total}
</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentIndex === 0}
onClick={onPrev}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentIndex === total - 1}
onClick={onNext}
>
<ChevronDown size={14} />
</Button>
</>
);
} else if (isFetching) {
content = <Loader className="animate-spin" />;
} else if (error) {
content = (
<TooltipRoot>
<TooltipTrigger asChild>
<span className={cx(styles.filterStatus, styles.hasError)}>
<Info />
API error
</span>
</TooltipTrigger>
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</TooltipRoot>
);
} else if (noData) {
content = (
<Typography.Text className={styles.filterStatus}>
No results found
</Typography.Text>
);
}
if (!content) {
return null;
}
return (
<>
{content}
{showNavigation && <span className={styles.resultNavDivider} />}
</>
);
}
QueryResult.defaultProps = {
showNavigation: true,
};
export default QueryResult;

View File

@@ -10,6 +10,7 @@ import (
"time"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/utils"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
@@ -76,7 +77,7 @@ func NewFieldMapper(fl flagger.Flagger) qbtypes.FieldMapper {
return &fieldMapper{fl: fl}
}
func (m *fieldMapper) getColumn(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
func (m *fieldMapper) getColumns(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
columns := []*schema.Column{logsV2Columns["resources_string"], logsV2Columns["resource"]}
@@ -245,7 +246,7 @@ func selectEvolutionsForColumns(columns []*schema.Column, evolutions []*telemetr
}
func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
columns, err := m.getColumn(ctx, key)
columns, err := m.getColumns(ctx, key)
if err != nil {
return "", err
}
@@ -287,12 +288,14 @@ func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *
return "", qbtypes.ErrColumnNotFound
}
expr, err := m.buildFieldForJSON(key)
if err != nil {
return "", err
}
for _, column := range columns {
expr, err := m.buildFieldForJSON(key, column)
if err != nil {
return "", err
}
exprs = append(exprs, expr)
exprs = append(exprs, expr)
}
default:
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource/body context fields are supported for json columns, got %s", key.FieldContext.String)
}
@@ -348,7 +351,7 @@ func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *
}
func (m *fieldMapper) ColumnFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
return m.getColumn(ctx, key)
return m.getColumns(ctx, key)
}
func (m *fieldMapper) ColumnExpressionFor(
@@ -403,16 +406,13 @@ func (m *fieldMapper) ColumnExpressionFor(
}
// buildFieldForJSON builds the field expression for body JSON fields using arrayConcat pattern.
func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (string, error) {
plan := key.JSONPlan
if len(plan) == 0 {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
"Could not find any valid paths for: %s", key.Name)
func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey, column *schemamigrator.Column) (string, error) {
node, err := key.PlanBuilder.Build(*column)
if err != nil {
return "", err
}
if plan[0].IsTerminal {
node := plan[0]
if node.IsTerminal {
expr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue())
// TODO(Piyush): Promoted path logic commented out. Materialized now means type hint
// promotion will be extracted from key field evolution
@@ -450,7 +450,7 @@ func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (
}
// Build arrayConcat pattern directly from the tree structure
arrayConcatExpr, err := m.buildArrayConcat(plan)
arrayConcatExpr, err := m.buildArrayConcat(node)
if err != nil {
return "", err
}
@@ -459,23 +459,18 @@ func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (
}
// buildArrayConcat builds the arrayConcat pattern directly from the tree structure.
func (m *fieldMapper) buildArrayConcat(plan telemetrytypes.JSONAccessPlan) (string, error) {
if len(plan) == 0 {
return "", errors.Newf(errors.TypeInternal, CodeGroupByPlanEmpty, "group by plan is empty while building arrayConcat")
}
func (m *fieldMapper) buildArrayConcat(node *telemetrytypes.JSONAccessNode) (string, error) {
// Build arrayMap expressions for ALL available branches at the root level.
// Iterate branches in deterministic order (JSON then Dynamic)
var arrayMapExpressions []string
for _, node := range plan {
for _, branchType := range node.BranchesInOrder() {
expr, err := m.buildArrayMap(node, branchType)
if err != nil {
return "", err
}
arrayMapExpressions = append(arrayMapExpressions, expr)
for _, branchType := range node.BranchesInOrder() {
expr, err := m.buildArrayMap(node, branchType)
if err != nil {
return "", err
}
arrayMapExpressions = append(arrayMapExpressions, expr)
}
if len(arrayMapExpressions) == 0 {
return "", errors.Newf(errors.TypeInternal, CodeArrayMapExpressionsEmpty, "array map expressions are empty while building arrayConcat")
}

View File

@@ -33,32 +33,42 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
}
// BuildCondition builds the full WHERE condition for body_v2 JSON paths.
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
baseCond, err := c.emitPlannedCondition(operator, value, sb)
if err != nil {
return "", err
}
// path index
if operator.AddDefaultExistsFilter() {
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
return sb.And(baseCond, pathIndex), nil
}
return baseCond, nil
}
func (c *jsonConditionBuilder) emitPlannedCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
// Build traversal + terminal recursively per-hop
conditions := []string{}
for _, node := range c.key.JSONPlan {
condition, err := c.recurseArrayHops(node, operator, value, sb)
func (c *jsonConditionBuilder) buildJSONCondition(columns []schemamigrator.Column, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if len(columns) == 1 {
baseCond, err := c.emitPlannedCondition(columns[0], operator, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, condition)
// path index
if operator.AddDefaultExistsFilter() {
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
return sb.And(baseCond, pathIndex), nil
}
return baseCond, nil
}
}
func (c *jsonConditionBuilder) emitPlannedCondition(column schemamigrator.Column, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if c.key.PlanBuilder == nil {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no plan builder for key %s", c.key.Name)
}
var conditions []string
node, err := c.key.PlanBuilder.Build(column)
if err != nil {
return "", err
}
cond, err := c.recurseArrayHops(node, operator, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
return sb.Or(conditions...), nil
}

View File

@@ -85,27 +85,16 @@ func (t *telemetryMetaStore) enrichJSONKeys(ctx context.Context, selectors []*te
key.Indexes = indexes[key.Name]
}
// build JSON access plans using the pre-fetched parent type cache
return t.buildJSONPlans(filteredKeys, parentTypeCache)
}
// buildJSONPlans builds JSON access plans for the given keys
// using the provided parent type cache (pre-fetched in the main UNION query).
func (t *telemetryMetaStore) buildJSONPlans(keys []*telemetrytypes.TelemetryFieldKey, typeCache map[string][]telemetrytypes.FieldDataType) error {
if len(keys) == 0 {
return nil
}
// attach a lazy PlanBuilder so field_mapper can build plans on demand
columnMeta := t.jsonColumnMetadata[telemetrytypes.SignalLogs][telemetrytypes.FieldContextBody]
for _, key := range keys {
if err := key.SetJSONAccessPlan(columnMeta, typeCache); err != nil {
return err
}
for _, key := range filteredKeys {
key.PlanBuilder = telemetrytypes.NewFieldPlanBuilder(key, columnMeta, parentTypeCache)
}
return nil
}
func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...string) (map[string][]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
filteredPaths := []string{}
for _, path := range paths {

View File

@@ -37,7 +37,7 @@ type TelemetryFieldKey struct {
FieldContext FieldContext `json:"fieldContext,omitzero"`
FieldDataType FieldDataType `json:"fieldDataType,omitzero"`
JSONPlan JSONAccessPlan `json:"-"`
PlanBuilder *FieldPlanBuilder `json:"-"`
Indexes []TelemetryFieldKeySkipIndex `json:"-"`
Materialized bool `json:"-"` // refers to promoted in case of body.... fields
@@ -120,7 +120,7 @@ func (f *TelemetryFieldKey) OverrideMetadataFrom(src *TelemetryFieldKey) {
f.FieldDataType = src.FieldDataType
f.Indexes = src.Indexes
f.Materialized = src.Materialized
f.JSONPlan = src.JSONPlan
f.PlanBuilder = src.PlanBuilder
f.Evolutions = src.Evolutions
}

View File

@@ -6,6 +6,7 @@ import (
"slices"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -28,8 +29,6 @@ type JSONColumnMetadata struct {
PromotedColumn string
}
type JSONAccessPlan = []*JSONAccessNode
type TerminalConfig struct {
Key *TelemetryFieldKey
ElemType JSONDataType
@@ -99,23 +98,48 @@ func (n *JSONAccessNode) BranchesInOrder() []JSONAccessBranchType {
})
}
type planBuilder struct {
// FieldPlanBuilder builds JSON access nodes on demand for a specific field key.
// It holds all necessary metadata (key, column info, type cache) and produces
// the correct root node per column, dispatching between base and promoted plans
// by column name — without any external "is promoted?" flag.
type FieldPlanBuilder struct {
key *TelemetryFieldKey
columnInfo JSONColumnMetadata
typeCache map[string][]FieldDataType
paths []string // cumulative paths for type cache lookups
segments []string // individual path segments for node names
isPromoted bool
typeCache map[string][]FieldDataType
}
func NewFieldPlanBuilder(key *TelemetryFieldKey, info JSONColumnMetadata, typeCache map[string][]FieldDataType) *FieldPlanBuilder {
return &FieldPlanBuilder{key: key, columnInfo: info, typeCache: typeCache}
}
// Build dispatches by column name — called by JSONConditionBuilder in field_mapper.
func (b *FieldPlanBuilder) Build(column schemamigrator.Column) (*JSONAccessNode, error) {
if column.Name == b.columnInfo.BaseColumn {
return b.build(NewRootJSONAccessNode(b.columnInfo.BaseColumn, 32, 0))
}
return b.build(NewRootJSONAccessNode(b.columnInfo.PromotedColumn, 32, 1024))
}
func (b *FieldPlanBuilder) build(root *JSONAccessNode) (*JSONAccessNode, error) {
if b.key.Name == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "path is empty")
}
b.paths = b.key.ArrayParentPaths()
b.segments = b.key.ArrayPathSegments()
return b.buildPlan(0, root, false)
}
// buildPlan recursively builds the path plan tree.
func (pb *planBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChild bool) (*JSONAccessNode, error) {
if index >= len(pb.paths) {
func (b *FieldPlanBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChild bool) (*JSONAccessNode, error) {
if index >= len(b.paths) {
return nil, errors.NewInvalidInputf(CodePlanIndexOutOfBounds, "index is out of bounds")
}
pathSoFar := pb.paths[index] // cumulative path for type cache lookup
segmentName := pb.segments[index] // segment name for node
isTerminal := index == len(pb.paths)-1
pathSoFar := b.paths[index] // cumulative path for type cache lookup
segmentName := b.segments[index] // segment name for node
isTerminal := index == len(b.paths)-1
// Calculate progression parameters based on parent's values
var maxTypes, maxPaths int
@@ -149,18 +173,18 @@ func (pb *planBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChil
// Configure terminal if this is the last part
if isTerminal {
// fielddatatype must not be unspecified else expression can not be generated
if pb.key.FieldDataType == FieldDataTypeUnspecified {
if b.key.FieldDataType == FieldDataTypeUnspecified {
return nil, errors.NewInternalf(CodePlanFieldDataTypeMissing, "field data type is missing for path %s", pathSoFar)
}
node.TerminalConfig = &TerminalConfig{
Key: pb.key,
ElemType: pb.key.GetJSONDataType(),
Key: b.key,
ElemType: b.key.GetJSONDataType(),
}
} else {
var err error
// Use cached types from the batched metadata query
types, ok := pb.typeCache[pathSoFar]
types, ok := b.typeCache[pathSoFar]
if !ok {
return nil, errors.NewInternalf(errors.CodeInvalidInput, "types missing for path %s", pathSoFar)
}
@@ -172,13 +196,13 @@ func (pb *planBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChil
}
if hasJSON {
node.Branches[BranchJSON], err = pb.buildPlan(index+1, node, false)
node.Branches[BranchJSON], err = b.buildPlan(index+1, node, false)
if err != nil {
return nil, err
}
}
if hasDynamic {
node.Branches[BranchDynamic], err = pb.buildPlan(index+1, node, true)
node.Branches[BranchDynamic], err = b.buildPlan(index+1, node, true)
if err != nil {
return nil, err
}
@@ -187,45 +211,3 @@ func (pb *planBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChil
return node, nil
}
// buildJSONAccessPlan builds a tree structure representing the complete JSON path traversal
// that precomputes all possible branches and their types.
func (key *TelemetryFieldKey) SetJSONAccessPlan(columnInfo JSONColumnMetadata, typeCache map[string][]FieldDataType,
) error {
// if path is empty, return nil
if key.Name == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "path is empty")
}
pb := &planBuilder{
key: key,
paths: key.ArrayParentPaths(),
segments: key.ArrayPathSegments(),
isPromoted: key.Materialized,
typeCache: typeCache,
}
node, err := pb.buildPlan(0,
NewRootJSONAccessNode(columnInfo.BaseColumn,
32, 0),
false,
)
if err != nil {
return err
}
key.JSONPlan = append(key.JSONPlan, node)
if pb.isPromoted {
node, err := pb.buildPlan(0,
NewRootJSONAccessNode(columnInfo.PromotedColumn,
32, 1024),
true,
)
if err != nil {
return err
}
key.JSONPlan = append(key.JSONPlan, node)
}
return nil
}