Compare commits

..

3 Commits

Author SHA1 Message Date
Gaurav Tewari
1d53e80f29 feat(ai-o11y): save explorer columns in AI saved views
An AI view saves the columns of the tab it was saved on; the other
tab keeps its defaults when the view opens.

Assisted-by: Claude Opus 5.5
2026-10-22 01:32:36 +05:30
Gaurav Tewari
7a9c4d4a0a feat(ai-o11y): keep trace view columns in explorer preferences
The trace view shares a page with the list view, so it writes
`traceViewOptions` instead of `options`. The URL wins over localStorage
so a shared link opens with the sender's columns.

Assisted-by: Claude Opus 5.5
2026-10-22 01:13:55 +05:30
Gaurav Tewari
e54ac6d6f7 feat(ai-o11y): keep list view columns in explorer preferences
AI queries run on traces, so the slice is keyed ai_observability
to keep its columns apart from the Traces Explorer's.

Assisted-by: Claude Opus 5.5
2026-10-19 23:09:17 +05:30
79 changed files with 1479 additions and 2190 deletions

View File

@@ -12,8 +12,10 @@ export enum LOCALSTORAGE {
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
AI_OBSERVABILITY_LIST_OPTIONS = 'AI_OBSERVABILITY_LIST_OPTIONS',
AI_OBSERVABILITY_TRACE_VIEW_OPTIONS = 'AI_OBSERVABILITY_TRACE_VIEW_OPTIONS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMN_SIZING = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMN_SIZING',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
@@ -189,6 +190,7 @@ function Explorer(): JSX.Element {
<QuickFiltersLayout
className="trace-explorer-page"
testId="llm-observability-explorer"
savedViewProps={{ source: SavedviewtypesSourceDTO.ai_observability }}
showFilters={isOpen}
onToggleFilters={(): void => setOpen(!isOpen)}
quickFilterProps={{

View File

@@ -12,7 +12,6 @@ import { QueryKey } from 'react-query';
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
@@ -20,7 +19,6 @@ import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { getTraceLink, transformSpanRows } from './utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -37,11 +35,8 @@ import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import { PER_PAGE_OPTIONS } from './configs';
import { useListViewColumns } from './useListViewColumns';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
@@ -83,6 +78,15 @@ function ListView({
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const {
selectedColumns,
selectColumnsSignature,
columns,
config,
onColumnOrderChange,
onColumnRemove,
} = useListViewColumns();
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
@@ -97,12 +101,14 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
],
@@ -123,7 +129,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: defaultSelectedColumns,
selectColumns: selectedColumns,
},
},
ENTITY_VERSION_V5,
@@ -156,15 +162,6 @@ function ListView({
[queryTableDataResult],
);
// TODO(ai-explorer): static columns until the preferences framework lands.
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
() =>
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
getFieldColumn(field),
),
[],
);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
@@ -204,6 +201,7 @@ function ListView({
isLoading={isFetching}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={config}
/>
</div>
@@ -211,7 +209,7 @@ function ListView({
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
respectColumnOrder={false}
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
@@ -219,6 +217,8 @@ function ListView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
/>
</div>
);

View File

@@ -0,0 +1,83 @@
import { useCallback, useMemo } from 'react';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import { defaultSelectedColumns, TIMESTAMP_FIELD } from './configs';
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
interface UseListViewColumns {
selectedColumns: TelemetryFieldKey[];
/** Changes on add/remove only, so a reorder doesn't refetch. */
selectColumnsSignature: string;
columns: TableColumnDef<TracesTableRow>[];
config: OptionsMenuConfig;
onColumnOrderChange: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove: (columnId: string) => void;
}
export function useListViewColumns(): UseListViewColumns {
const { preferences, updateColumns } = usePreferenceContext().aiObservability;
const selectedColumns = useMemo(
() =>
(preferences?.columns ?? defaultSelectedColumns).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
),
[preferences?.columns],
);
const selectColumnsSignature = useMemo(
() =>
selectedColumns
.map((field) => field.name)
.sort()
.join(','),
[selectedColumns],
);
const columns = useMemo(
() => [TIMESTAMP_FIELD, ...selectedColumns].map(getFieldColumn),
[selectedColumns],
);
const config = useMemo<OptionsMenuConfig>(
() => ({
fieldsSelector: { value: selectedColumns, onFieldsChange: updateColumns },
}),
[selectedColumns, updateColumns],
);
const onColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
const byId = new Map(selectedColumns.map((f) => [columnIdOf(f), f]));
updateColumns(
cols
.map((col) => byId.get(col.id))
.filter((f): f is TelemetryFieldKey => !!f),
);
},
[selectedColumns, updateColumns],
);
const onColumnRemove = useCallback(
(columnId: string): void => {
updateColumns(selectedColumns.filter((f) => columnIdOf(f) !== columnId));
},
[selectedColumns, updateColumns],
);
return {
selectedColumns,
selectColumnsSignature,
columns,
config,
onColumnOrderChange,
onColumnRemove,
};
}

View File

@@ -6,7 +6,7 @@ import { render, screen, userEvent } from 'tests/test-utils';
import { buildTraceViewColumns } from '../../TracesView/configs';
import TracesTable from '../TracesTable';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMN_SIZING;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
@@ -17,7 +17,11 @@ const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'unlisted_aggregate' },
]);
]).map((column) =>
column.id === 'unlisted_aggregate'
? { ...column, defaultVisibility: false }
: column,
);
function RaceHarness(): JSX.Element {
const [columnsReady, setColumnsReady] = useState(false);

View File

@@ -69,6 +69,8 @@ function TracesView({
columns,
selectedFields,
onFieldsChange,
onColumnOrderChange,
onColumnRemove,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
@@ -183,15 +185,6 @@ function TracesView({
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
@@ -226,13 +219,13 @@ function TracesView({
<TracesTable
data={rows}
columns={tableColumns}
columns={columns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMN_SIZING
: undefined
}
respectColumnOrder
respectColumnOrder={false}
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading || isColumnsLoading}
@@ -240,6 +233,8 @@ function TracesView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
/>
</div>
);

View File

@@ -6,14 +6,12 @@ import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import TracesView from '../TracesView';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_OPTIONS;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
@@ -84,11 +82,6 @@ const mockFieldKeysFailure = (): void => {
);
};
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
const renderTracesView = (): ReturnType<typeof render> =>
render(
<TracesView
@@ -109,7 +102,6 @@ const renderTracesView = (): ReturnType<typeof render> =>
describe('TracesView column persistence', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
mockRows();
});
@@ -121,16 +113,15 @@ describe('TracesView column persistence', () => {
// Rows are virtualised, so a mounted table stands in for "rows arrived".
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
it('seeds the persisted defaults once the field keys arrive', async () => {
it('shows the default columns once the field keys arrive', async () => {
mockFieldKeys(['llm_call_count', 'tool_call_count']);
renderTracesView();
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
@@ -140,8 +131,7 @@ describe('TracesView column persistence', () => {
await findTable();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('drops the column picker when the field keys fail', async () => {
@@ -166,18 +156,23 @@ describe('TracesView column persistence', () => {
});
it('leaves an existing selection untouched while the field keys fail', async () => {
const existing = {
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
columnOrder: ['trace_id', 'resource:service.name'],
columnSizing: {},
};
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
const existing = JSON.stringify({
selectColumns: [
{ name: 'trace_id' },
{
name: 'tool_call_count',
fieldContext: 'trace',
fieldDataType: 'float64',
},
],
});
localStorage.setItem(STORAGE_KEY, existing);
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(persistedState()).toStrictEqual(existing);
expect(localStorage.getItem(STORAGE_KEY)).toBe(existing);
});
});

View File

@@ -1,6 +1,7 @@
/* eslint-disable no-restricted-syntax */
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { act, renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
@@ -9,13 +10,28 @@ import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { AI_OBSERVABILITY_TRACE_VIEW_URL_OPTIONS } from 'providers/preferences/types';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { useTraceViewColumns } from '../useTraceViewColumns';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
// The loader reads window.location, which MemoryRouter never moves.
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: (url: string) => void } => ({
safeNavigate: (url: string): void => window.history.replaceState({}, '', url),
}),
}));
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_OPTIONS;
const storedColumnNames = (): string[] | undefined =>
(
JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as {
selectColumns: TelemetryFieldKey[];
} | null
)?.selectColumns.map((field) => field.name);
const AGGREGATE_KEYS = [
'llm_call_count',
@@ -39,7 +55,11 @@ function wrapper({ children }: { children: ReactNode }): JSX.Element {
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<PreferenceContextProvider>{children}</PreferenceContextProvider>
</QueryClientProvider>
</MemoryRouter>
);
}
@@ -90,8 +110,8 @@ const renderColumns = async (): Promise<
describe('useTraceViewColumns', () => {
beforeEach(() => {
seenAI.length = 0;
useColumnStore.getState().tables = {};
localStorage.clear();
window.history.replaceState({}, '', '/');
mockAggregateKeys(AGGREGATE_KEYS);
});
@@ -160,10 +180,22 @@ describe('useTraceViewColumns', () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
expect(fieldNames(result.current.selectedFields)).not.toContain(
'brand_new_aggregate',
);
act(() => {
result.current.onFieldsChange([
...result.current.selectedFields,
{
name: 'brand_new_aggregate',
fieldContext: 'trace',
fieldDataType: 'float64',
},
]);
});
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
});
describe('when the keys fetch fails', () => {
@@ -179,10 +211,7 @@ describe('useTraceViewColumns', () => {
it('does not persist defaults', async () => {
await renderColumns();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
).toBeNull();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('reports the column state as not persistable', async () => {
@@ -198,7 +227,7 @@ describe('useTraceViewColumns', () => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('seeds the defaults once a later fetch succeeds', async () => {
@@ -291,12 +320,72 @@ describe('useTraceViewColumns', () => {
'trace_id',
'service.name',
]);
expect(
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
).toStrictEqual([
'trace:total_tokens:float64',
expect(storedColumnNames()).toStrictEqual([
'total_tokens',
'trace_id',
'resource:service.name',
'service.name',
]);
});
it('writes the selection to its own URL param, not the list view one', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
});
expect(window.location.search).not.toContain('options=');
const params = new URLSearchParams(window.location.search);
expect(
JSON.parse(params.get(AI_OBSERVABILITY_TRACE_VIEW_URL_OPTIONS) ?? '{}')
.selectColumns,
).toStrictEqual([{ name: 'trace_id' }, { name: 'input' }]);
});
it('takes the URL columns over the stored ones', async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ selectColumns: [{ name: 'trace_id' }, { name: 'input' }] }),
);
window.history.replaceState(
{},
'',
`/?${AI_OBSERVABILITY_TRACE_VIEW_URL_OPTIONS}=${encodeURIComponent(
JSON.stringify({
selectColumns: [{ name: 'output' }, { name: 'trace_id' }],
}),
)}`,
);
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'output',
'trace_id',
]);
});
it('drops stored fields the keys endpoint no longer reports', async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
selectColumns: [{ name: 'trace_id' }, { name: 'removed_aggregate' }],
}),
);
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual(['trace_id']);
});
it('removes a column dropped from the table header', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onColumnRemove('input');
});
expect(fieldNames(result.current.selectedFields)).not.toContain('input');
expect(storedColumnNames()).not.toContain('input');
});
});

View File

@@ -40,15 +40,17 @@ export const sortByDefaultOrder = (
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied until the user picks columns. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
export const isDefaultVisible = (field: TelemetryFieldKey): boolean =>
DEFAULT_VISIBLE_FIELDS.has(field.name);
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
): TableColumnDef<TracesTableRow>[] =>
fields.map((field) => ({
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: true,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,

View File

@@ -1,17 +1,9 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { mergeExtraFields } from 'utils/extraFields';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
hideColumn,
initializeFromDefaults,
setColumnOrder,
showColumn,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
@@ -23,28 +15,35 @@ import {
} from '../constants';
import {
buildTraceViewColumns,
isDefaultVisible,
sortByDefaultOrder,
TRACE_ID_COLUMN_ID,
} from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
const isTraceId = (field: TelemetryFieldKey): boolean =>
columnIdOf(field) === TRACE_ID_COLUMN_ID;
interface UseTraceViewColumns {
/** Visible columns, in the user's order. */
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
onColumnOrderChange: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove: (columnId: string) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const { preferences, updateColumns } =
usePreferenceContext().aiObservabilityTraceView;
const {
data: fetchedFields = [],
isFetched,
@@ -66,60 +65,69 @@ export function useTraceViewColumns(): UseTraceViewColumns {
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
// Stored fields resolve against the pool, so a field that is gone drops out; none left means the defaults.
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
const byId = new Map(
availableFields.map((field) => [columnIdOf(field), field]),
);
const stored = (preferences?.columns ?? [])
.map((field) => byId.get(columnIdOf(field)))
.filter((field): field is TelemetryFieldKey => !!field);
const selected = stored.length
? stored
: availableFields.filter(isDefaultVisible);
const traceId = byId.get(TRACE_ID_COLUMN_ID);
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
return selected.some(isTraceId) || !traceId
? selected
: [traceId, ...selected];
}, [availableFields, preferences?.columns]);
const columns = useMemo(
() => buildTraceViewColumns(selectedFields),
[selectedFields],
);
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
showColumn(STORAGE_KEY, column.id);
} else {
hideColumn(STORAGE_KEY, column.id);
}
});
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
const traceId = selectedFields.find(isTraceId);
updateColumns(next.some(isTraceId) || !traceId ? next : [traceId, ...next]);
},
[columns, isSuccess],
[isSuccess, selectedFields, updateColumns],
);
const onColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
const byId = new Map(
selectedFields.map((field) => [columnIdOf(field), field]),
);
onFieldsChange(
cols
.map((col) => byId.get(col.id))
.filter((field): field is TelemetryFieldKey => !!field),
);
},
[selectedFields, onFieldsChange],
);
const onColumnRemove = useCallback(
(columnId: string): void => {
onFieldsChange(
selectedFields.filter((field) => columnIdOf(field) !== columnId),
);
},
[selectedFields, onFieldsChange],
);
return {
columns,
selectedFields,
onFieldsChange,
onColumnOrderChange,
onColumnRemove,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,

View File

@@ -1,27 +1,15 @@
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SavedviewtypesSchemaVersionDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import {
buildSavedViewDeletePermission,
buildSavedViewUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
// Only the logs and traces explorers have columns and formatting to save.
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
Record<SavedviewtypesSourceDTO, DataSource>
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
// What selecting a view writes to the url, so clearing it removes exactly these.
export const SAVED_VIEW_URL_PARAMS = [
QueryParams.viewKey,

View File

@@ -2,18 +2,16 @@ import { useEffect, useMemo, useRef } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
import { useSavedViewOptions } from './useSavedViewOptions';
export function useActiveSavedView(
source: SavedviewtypesSourceDTO,
@@ -21,10 +19,7 @@ export function useActiveSavedView(
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { preferenceSource, options } = useSavedViewOptions(source, panelType);
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
[buildSavedViewReadPermission(viewKey)],
@@ -63,7 +58,8 @@ export function useActiveSavedView(
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
preferenceSource,
options,
});
}, [
view,
@@ -71,7 +67,7 @@ export function useActiveSavedView(
isStagedQueryCurrent,
stagedQuery,
panelType,
optionsDataSource,
preferenceSource,
options,
]);

View File

@@ -15,27 +15,29 @@ import {
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
SAVED_VIEW_OPTIONS_DATA_SOURCE,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import { UseSavedViewActionsResult } from '../types';
import {
getPreferenceSlice,
getSavedViewPreferenceSource,
} from '../utils/getSavedViewPreferenceSource';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
import { useSavedViewOptions } from './useSavedViewOptions';
// Columns and formatting need no handling here: with a viewKey in the url the
// preference provider reads them from the view, and without one it falls back
@@ -51,15 +53,8 @@ export function useSavedViewActions(
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { options } = useSavedViewOptions(source, panelType);
const preferences = usePreferenceContext();
const preferencesSlice =
optionsDataSource === DataSource.TRACES
? preferences.traces
: preferences.logs;
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
@@ -77,10 +72,10 @@ export function useSavedViewActions(
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
displayName,
options: optionsDataSource ? options : undefined,
options,
color,
}),
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
[stagedQuery, currentQuery, panelType, options],
);
const selectView = useCallback(
@@ -96,19 +91,27 @@ export function useSavedViewActions(
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (optionsDataSource) {
const viewPreferenceSource = getSavedViewPreferenceSource(
source,
view.spec.panelType,
);
const viewPreferencesSlice = getPreferenceSlice(
preferences,
viewPreferenceSource,
);
if (viewPreferenceSource && viewPreferencesSlice) {
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
optionsDataSource,
viewPreferenceSource,
);
preferencesSlice.updateColumns(columns);
viewPreferencesSlice.updateColumns(columns);
if (formatting) {
preferencesSlice.updateFormatting(formatting);
viewPreferencesSlice.updateFormatting(formatting);
}
}
selectView(view);
},
[optionsDataSource, preferencesSlice, selectView],
[source, preferences, selectView],
);
// The query builder redirects only ever add params, so the url is rebuilt

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { defaultOptionsQuery } from 'container/OptionsMenu/constants';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { PreferenceSource } from 'providers/preferences/types';
import { SavedViewOptions } from '../types';
import {
getPreferenceSlice,
getSavedViewPreferenceSource,
} from '../utils/getSavedViewPreferenceSource';
interface UseSavedViewOptionsResult {
preferenceSource?: PreferenceSource;
options?: SavedViewOptions;
}
export function useSavedViewOptions(
source: SavedviewtypesSourceDTO,
panelType: string | null | undefined,
): UseSavedViewOptionsResult {
const preferences = usePreferenceContext();
const preferenceSource = getSavedViewPreferenceSource(source, panelType);
const preferencesSlice = getPreferenceSlice(preferences, preferenceSource);
const slicePreferences = preferencesSlice?.preferences;
const options = useMemo((): SavedViewOptions | undefined => {
if (!preferencesSlice) {
return undefined;
}
return {
selectColumns: slicePreferences?.columns || [],
format: slicePreferences?.formatting?.format || defaultOptionsQuery.format,
maxLines:
slicePreferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize:
slicePreferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
};
}, [preferencesSlice, slicePreferences]);
return { preferenceSource, options };
}

View File

@@ -7,6 +7,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { PreferenceSource } from 'providers/preferences/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -29,6 +30,7 @@ export interface HasUnsavedViewChangesArgs {
view: SavedviewtypesSavedViewDTO;
stagedQuery: Query | null;
panelType: PANEL_TYPES;
preferenceSource?: PreferenceSource;
options?: SavedViewOptions;
}

View File

@@ -0,0 +1,85 @@
import {
SavedviewtypesPanelTypeDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { defaultSelectedColumns as defaultAIListColumns } from 'container/LLMObservability/Explorer/ListView/configs';
import {
AI_OBSERVABILITY_PREFERENCE_SOURCE,
AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
} from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
import { getSavedViewPreferenceSource } from '../getSavedViewPreferenceSource';
import { getViewColumnsAndFormatting } from '../getViewColumnsAndFormatting';
const traceColumns = [
{ name: 'trace_id' },
{ name: 'total_tokens' },
] as TelemetryFieldKey[];
const specFor = (
panelType: SavedviewtypesPanelTypeDTO,
): SavedviewtypesSavedViewSpecDTO =>
({
displayName: 'Costly traces',
panelType,
queries: [],
selectedFields: traceColumns,
}) as unknown as SavedviewtypesSavedViewSpecDTO;
describe('getSavedViewPreferenceSource', () => {
it.each([
[SavedviewtypesSourceDTO.logs, PANEL_TYPES.LIST, DataSource.LOGS],
[SavedviewtypesSourceDTO.traces, PANEL_TYPES.TRACE, DataSource.TRACES],
[
SavedviewtypesSourceDTO.ai_observability,
PANEL_TYPES.LIST,
AI_OBSERVABILITY_PREFERENCE_SOURCE,
],
[
SavedviewtypesSourceDTO.ai_observability,
PANEL_TYPES.TRACE,
AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
],
[
SavedviewtypesSourceDTO.ai_observability,
PANEL_TYPES.TIME_SERIES,
undefined,
],
[SavedviewtypesSourceDTO.metrics, PANEL_TYPES.LIST, undefined],
])('maps %s on %s to %s', (source, panelType, expected) => {
expect(getSavedViewPreferenceSource(source, panelType)).toBe(expected);
});
});
describe('getViewColumnsAndFormatting for AI views', () => {
it('gives a trace view its own columns', () => {
expect(
getViewColumnsAndFormatting(
specFor(SavedviewtypesPanelTypeDTO.trace),
AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
).columns,
).toStrictEqual(traceColumns);
});
it('keeps the list tab on its defaults for a trace view', () => {
expect(
getViewColumnsAndFormatting(
specFor(SavedviewtypesPanelTypeDTO.trace),
AI_OBSERVABILITY_PREFERENCE_SOURCE,
).columns,
).toStrictEqual(defaultAIListColumns);
});
it('keeps the trace tab on its defaults for a list view', () => {
expect(
getViewColumnsAndFormatting(
specFor(SavedviewtypesPanelTypeDTO.list),
AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
).columns,
).toStrictEqual([]);
});
});

View File

@@ -120,6 +120,7 @@ describe('hasUnsavedViewChanges', () => {
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
preferenceSource: DataSource.TRACES,
options: { ...options, selectColumns: [columns[0]] },
}),
).toBe(true);
@@ -139,6 +140,7 @@ describe('hasUnsavedViewChanges', () => {
view: bare,
stagedQuery: stagedFrom(bare, DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
preferenceSource: DataSource.TRACES,
options: { ...options, selectColumns: defaultTraceSelectedColumns },
}),
).toBe(false);
@@ -163,6 +165,7 @@ describe('hasUnsavedViewChanges', () => {
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
preferenceSource: DataSource.LOGS,
options: { selectColumns: shownColumns, ...formatting },
}),
).toBe(false);
@@ -174,6 +177,7 @@ describe('hasUnsavedViewChanges', () => {
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
preferenceSource: DataSource.LOGS,
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
}),
).toBe(true);
@@ -193,6 +197,7 @@ describe('hasUnsavedViewChanges', () => {
view: bare,
stagedQuery: stagedFrom(bare, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
preferenceSource: DataSource.LOGS,
options: {
selectColumns: defaultLogsSelectedColumns,
format: 'table',
@@ -373,6 +378,7 @@ describe('hasUnsavedViewChanges', () => {
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
preferenceSource: DataSource.TRACES,
options: { ...options, ...formatting, maxLines: 5 },
}),
).toBe(false);

View File

@@ -0,0 +1,52 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
AI_OBSERVABILITY_PREFERENCE_SOURCE,
AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
PreferenceContextValue,
PreferenceSlice,
PreferenceSource,
} from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
// The preference slice whose columns a view of this source and panel saves;
// none when that explorer tab has no columns. The AI explorer keeps one per tab.
export function getSavedViewPreferenceSource(
source: SavedviewtypesSourceDTO,
panelType: string | null | undefined,
): PreferenceSource | undefined {
switch (source) {
case SavedviewtypesSourceDTO.logs:
return DataSource.LOGS;
case SavedviewtypesSourceDTO.traces:
return DataSource.TRACES;
case SavedviewtypesSourceDTO.ai_observability:
if (panelType === PANEL_TYPES.LIST) {
return AI_OBSERVABILITY_PREFERENCE_SOURCE;
}
if (panelType === PANEL_TYPES.TRACE) {
return AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE;
}
return undefined;
default:
return undefined;
}
}
export function getPreferenceSlice(
preferences: PreferenceContextValue,
preferenceSource: PreferenceSource | undefined,
): PreferenceSlice | undefined {
switch (preferenceSource) {
case DataSource.LOGS:
return preferences.logs;
case DataSource.TRACES:
return preferences.traces;
case AI_OBSERVABILITY_PREFERENCE_SOURCE:
return preferences.aiObservability;
case AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE:
return preferences.aiObservabilityTraceView;
default:
return undefined;
}
}

View File

@@ -1,4 +1,7 @@
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
SavedviewtypesPanelTypeDTO,
SavedviewtypesSavedViewSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
@@ -6,7 +9,13 @@ import {
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Preferences } from 'providers/preferences/types';
import { defaultSelectedColumns as defaultAIObservabilitySelectedColumns } from 'container/LLMObservability/Explorer/ListView/configs';
import {
AI_OBSERVABILITY_PREFERENCE_SOURCE,
AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
Preferences,
PreferenceSource,
} from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
function withColumnNames(
@@ -25,7 +34,7 @@ function withColumnNames(
// defaults filled in where the view has none.
export function getViewColumnsAndFormatting(
spec: SavedviewtypesSavedViewSpecDTO | undefined,
dataSource: DataSource,
dataSource: PreferenceSource,
): Preferences {
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
@@ -45,5 +54,20 @@ export function getViewColumnsAndFormatting(
if (dataSource === DataSource.TRACES) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
// An AI view saves the columns of its own tab only; the other tab keeps its defaults.
if (dataSource === AI_OBSERVABILITY_PREFERENCE_SOURCE) {
return {
columns:
(spec?.panelType === SavedviewtypesPanelTypeDTO.list && selectedFields) ||
defaultAIObservabilitySelectedColumns,
};
}
if (dataSource === AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE) {
return {
columns:
(spec?.panelType === SavedviewtypesPanelTypeDTO.trace && selectedFields) ||
[],
};
}
return { columns: [] };
}

View File

@@ -49,6 +49,7 @@ export function hasUnsavedViewChanges({
view,
stagedQuery,
panelType,
preferenceSource,
options,
}: HasUnsavedViewChangesArgs): boolean {
if (!stagedQuery) {
@@ -72,14 +73,13 @@ export function hasUnsavedViewChanges({
return true;
}
if (!options) {
if (!options || !preferenceSource) {
return false;
}
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,
preferenceSource,
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);
@@ -87,7 +87,7 @@ export function hasUnsavedViewChanges({
return true;
}
if (dataSource !== DataSource.LOGS || !formatting) {
if (preferenceSource !== DataSource.LOGS || !formatting) {
return false;
}
return (

View File

@@ -1,58 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { useCopySpanLink } from '../useCopySpanLink';
const mockCopy = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn() },
}));
// The router only knows the stale URL; nuqs has since written ?tab=thread.
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<MemoryRouter initialEntries={['/trace/abc?tab=overview&spanId=old']}>
{children}
</MemoryRouter>
);
const clickEvent = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as MouseEvent<HTMLElement>;
describe('useCopySpanLink', () => {
beforeEach(() => {
mockCopy.mockClear();
__setSearchParamsGetterForTest(
() => new URLSearchParams('tab=thread&spanId=old'),
);
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('builds the link from the live URL, keeping its tab', () => {
const { result } = renderHook(() => useCopySpanLink({ span_id: 'new' }), {
wrapper,
});
result.current.onSpanCopy(clickEvent);
const link = new URL(mockCopy.mock.calls[0][0]);
expect(link.pathname).toBe('/trace/abc');
expect(link.searchParams.get('tab')).toBe('thread');
expect(link.searchParams.get('spanId')).toBe('new');
});
});

View File

@@ -2,8 +2,8 @@ import { MouseEventHandler, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import useUrlQuery from 'hooks/useUrlQuery';
import { getAbsoluteUrl } from 'utils/basePath';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
// Accepts both V2 (spanId) and V3 (span_id) span shapes
// TODO: Remove V2 (spanId) support when phasing out V2
@@ -15,6 +15,7 @@ interface SpanLike {
export const useCopySpanLink = (
span?: SpanLike,
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
@@ -27,22 +28,21 @@ export const useCopySpanLink = (
event.preventDefault();
event.stopPropagation();
const params = getUnstableCurrentSearchParams();
params.delete('spanId');
urlQuery.delete('spanId');
const id = span.span_id || span.spanId;
if (id) {
params.set('spanId', id);
urlQuery.set('spanId', id);
}
const link = getAbsoluteUrl(`${pathname}?${params.toString()}`);
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', {
position: 'top-right',
});
},
[span, pathname, setCopy],
[span, urlQuery, pathname, setCopy],
);
return {

View File

@@ -1,11 +1,11 @@
import { ReactNode } from 'react';
import { Tooltip, TooltipProps } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: TooltipProps['title'];
tooltip: string;
icon?: ReactNode;
children: ReactNode;
}

View File

@@ -8,12 +8,3 @@
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
--typography-font-size: var(--periscope-font-size-base);
}
.tokenTooltip {
composes: tooltipBody from '../shared.module.scss';
min-width: 180px;
}
.tokenTooltipTitle {
padding-bottom: var(--spacing-4);
}

View File

@@ -1,18 +1,10 @@
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
import { CalendarClock, Server, Timer } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import {
AiTokenCounts,
formatCost,
formatTokens,
getTotalInputTokens,
hasValue,
} from '../utils/aiTraceUtils';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import styles from './EntityMetadataRow.module.scss';
@@ -24,8 +16,6 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: AiTokenCounts;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -42,11 +32,8 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const totalInput = tokens && getTotalInputTokens(tokens);
const durationTooltip =
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
// Single source of duration formatting so both rows label units identically.
@@ -104,24 +91,6 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{hasValue(cost) && (
<EntityMetadataItem
tooltip="Total cost"
icon={<Landmark size={ICON_SIZE} />}
>
Cost — {formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -133,8 +102,6 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -1,35 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import { AiTokenCounts } from '../utils/aiTraceUtils';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: AiTokenCounts;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
return (
<div className={styles.tokenTooltip}>
<Typography.Text
size="small"
weight="normal"
className={styles.tokenTooltipTitle}
>
Usage Breakdown
</Typography.Text>
<TokenBreakdown
input={input}
output={output}
cacheRead={cacheRead}
cacheWrite={cacheWrite}
reasoning={reasoning}
/>
</div>
);
}
export default TokenUsageTooltip;

View File

@@ -1,11 +1,9 @@
import { useMemo } from 'react';
import { Link2 } from '@signozhq/icons';
import dayjs from 'dayjs';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
LinkedSpansPanel,
@@ -33,10 +31,6 @@ function SpanSummary({
}: SpanSummaryProps): JSX.Element {
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
const aiUsage = useMemo(
() => getAIUsageDetails(selectedSpan)?.usage,
[selectedSpan],
);
return (
<>
@@ -69,8 +63,6 @@ function SpanSummary({
: undefined
}
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
tokens={aiUsage && convertToAiTokens(aiUsage)}
cost={aiUsage?.cost}
/>
<div className={styles.spanInfoItem}>

View File

@@ -95,26 +95,4 @@ describe('SpanSummary', () => {
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
});
it('renders tokens and cost from the gen_ai usage attributes', () => {
const span = {
...createMockSpan(),
attributes: {
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 9,
'signoz.gen_ai.usage.tokens.cost': 0.000009,
},
};
render(<SpanSummary selectedSpan={span} />);
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
});
it('omits tokens and cost for non-AI spans', () => {
render(<SpanSummary selectedSpan={createMockSpan()} />);
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
});
});

View File

@@ -1,37 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAiSpanKind } from '../utils/aiTraceUtils';
import { AI_SPAN_BADGES } from './constants';
interface SpanHintBadgeProps {
span: SpanV3;
}
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
const aiSpanKind = getAiSpanKind(span);
if (aiSpanKind) {
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
return (
<span style={{ display: 'inline-flex' }}>
<Badge
variant="outlined"
color={color}
testId={`span-hint-badge-${aiSpanKind}`}
>
{label}
</Badge>
</span>
);
}
if (!span.response_status_code) {
return null;
}
return <HttpStatusBadge statusCode={span.response_status_code} />;
}
export default SpanHintBadge;

View File

@@ -1,12 +0,0 @@
import type { BadgeColorType } from '@signozhq/ui/badge';
import { AiSpanKind } from '../utils/aiTraceUtils';
export const AI_SPAN_BADGES: Record<
AiSpanKind,
{ label: string; color: BadgeColorType }
> = {
[AiSpanKind.Llm]: { label: 'LLM', color: 'success' },
[AiSpanKind.Tool]: { label: 'TOOL', color: 'warning' },
[AiSpanKind.Agent]: { label: 'AGENT', color: 'highlight-danger' },
};

View File

@@ -8,12 +8,16 @@
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l2-foreground);
margin-bottom: 6px;
}
.name {

View File

@@ -9,17 +9,28 @@
}
.content {
composes: tooltipBody from '../shared.module.scss';
min-width: 223px;
max-width: 360px;
font-size: 12px;
color: var(--l1-foreground);
}
.title {
composes: tooltipTitle from '../shared.module.scss';
.name {
font-weight: 600;
margin-bottom: 4px;
}
.header {
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-2);
.row {
line-height: 1.5;
color: var(--l2-foreground);
}
.preview {
// container for additional preview rows
}
.previewKey {
color: var(--l2-foreground);
}
.previewValue {
color: var(--l1-foreground);
}

View File

@@ -1,5 +1,4 @@
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
@@ -8,11 +7,6 @@ import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
import SpanUsageBreakdown from './SpanUsageBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
import styles from './SpanHoverCard.module.scss';
/**
@@ -39,7 +33,6 @@ export interface SpanTooltipContentProps {
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
ai?: SpanAiDetails;
}
export function SpanTooltipContent({
@@ -49,60 +42,33 @@ export function SpanTooltipContent({
relativeStartMs,
durationMs,
previewRows,
ai,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className={styles.content}>
<div className={styles.header}>
<Typography.Text
className={styles.title}
size="small"
weight="normal"
style={{ color }}
>
{spanName}
</Typography.Text>
{ai?.usage && (
<Typography.Text size="small" weight="normal">
Usage Breakdown
</Typography.Text>
)}
<div className={styles.name} style={{ color }}>
{spanName}
</div>
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
<div className={styles.row}>
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
</div>
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
<TraceTooltipSection>
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
<TraceTooltipRow
label="status"
value={hasError ? 'error' : 'ok'}
testId="span-hover-card-status"
/>
<TraceTooltipRow
label="start"
value={`${toFixed(relativeStartMs, 2)} ms`}
testId="span-hover-card-start"
/>
<TraceTooltipRow
label="duration"
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
testId="span-hover-card-duration"
/>
</TraceTooltipSection>
{previewRows && previewRows.length > 0 && (
<TraceTooltipSection>
<div className={styles.preview}>
{previewRows.map((row) => (
<TraceTooltipRow
<div
key={row.key}
label={row.key}
value={row.value}
testId={`span-hover-card-preview-${row.key}`}
/>
className={styles.row}
data-testid={`span-hover-card-preview-${row.key}`}
>
<span className={styles.previewKey}>{row.key}:</span>{' '}
<span className={styles.previewValue}>{row.value}</span>
</div>
))}
</TraceTooltipSection>
</div>
)}
</div>
);
@@ -164,7 +130,6 @@ export function SpanHoverCard({
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
ai: getAIUsageDetails(span),
},
};
}, [

View File

@@ -1,38 +0,0 @@
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
interface SpanUsageBreakdownProps {
usage: SpanAiUsage;
}
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
const {
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
cost,
} = usage;
return (
<>
<TokenBreakdown
input={inputTokens}
output={outputTokens}
cacheRead={cacheReadTokens}
cacheWrite={cacheCreationTokens}
reasoning={reasoningTokens}
/>
{hasValue(cost) && (
<TraceTooltipSection>
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
</TraceTooltipSection>
)}
</>
);
}
export default SpanUsageBreakdown;

View File

@@ -1,81 +0,0 @@
import {
AiTokenCounts,
formatTokens,
getTokenLabel,
getTotalInputTokens,
} from '../utils/aiTraceUtils';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
type TokenBreakdownProps = AiTokenCounts;
function TokenBreakdown({
input,
output,
cacheRead,
cacheWrite,
reasoning,
}: TokenBreakdownProps): JSX.Element {
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
return (
<>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Total Input', totalInput)}
value={formatTokens(totalInput)}
isHeading
/>
<TraceTooltipRow
label={getTokenLabel('Cache Read', cacheRead)}
value={formatTokens(cacheRead)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Cache Write', cacheWrite)}
value={formatTokens(cacheWrite)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Input', input)}
value={formatTokens(input)}
isNested
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Reasoning', reasoning)}
value={formatTokens(reasoning)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Output', output)}
value={formatTokens(output)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label="Total Token Usage"
value={formatTokens(totalInput + (output ?? 0))}
isHeading
/>
</TraceTooltipSection>
</>
);
}
TokenBreakdown.defaultProps = {
input: undefined,
output: undefined,
cacheRead: undefined,
cacheWrite: undefined,
reasoning: undefined,
};
export default TokenBreakdown;

View File

@@ -29,10 +29,6 @@
}
}
.tabs {
flex-shrink: 0;
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
@@ -13,20 +13,16 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { useTraceStore } from '../stores/traceStore';
import TraceDownloadPanel from './TraceDownloadPanel';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
import MissingSpansBanner from './MissingSpansBanner';
import { useTraceSummary } from './useTraceSummary';
import TraceDetailsTabs from './TraceDetailsTabs';
import TraceOptionsMenu from './TraceOptionsMenu';
import { useTraceSummary } from './useTraceSummary';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -64,14 +60,6 @@ function TraceDetailsHeader({
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const { data: traceSummary } = useTraceSummary(traceID);
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
// Overview-only panels must not linger over another tab.
useEffect(() => {
setIsPreviewFieldsOpen(false);
setIsAnalyticsOpen(false);
}, [tab]);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
@@ -105,14 +93,13 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
useTraceDetailsStripInfo({
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
});
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
const durationMs = traceSummary
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
: 0;
return (
<div className={styles.wrapper}>
<div className={styles.header}>
@@ -133,10 +120,9 @@ function TraceDetailsHeader({
badgeValue={traceID}
maxCharacters={100}
/>
<TraceDetailsTabs />
</div>
)}
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
{showTraceDetailsHeaderOptions && traceSummary && (
<div
className={cx(
styles.filterSection,
@@ -152,7 +138,6 @@ function TraceDetailsHeader({
icon
color="secondary"
aria-label="Analytics"
testId="trace-analytics-button"
onClick={handleToggleAnalytics}
>
<ChartPie size={14} />
@@ -187,32 +172,28 @@ function TraceDetailsHeader({
)}
</div>
{(showTraceDetails || !isOverview) && (
{showTraceDetails && (
<div className={styles.subHeader}>
{!traceSummary ? (
<DetailsLoader />
) : (
{traceSummary ? (
<EntityMetadataRow
entity="trace"
service={{
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
}}
durationMs={
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
}
durationMs={durationMs}
timestamp={dayjs(traceSummary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceSummary.rootSpanStatusCode}
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />
)}
</div>
)}
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -1,43 +0,0 @@
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { TraceDetailsTab } from '../constants';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
const TAB_ITEMS = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
},
];
function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
void setTab(value as TraceDetailsTab);
};
return (
<div className={styles.tabs}>
<ToggleGroup
variant="outlined"
color="secondary"
type="single"
value={tab}
onChange={handleChange}
size="sm"
items={TAB_ITEMS}
/>
</div>
);
}
export default TraceDetailsTabs;

View File

@@ -50,7 +50,6 @@ function TraceOptionsMenu({
type: 'item',
value: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
testId: 'toggle-trace-details',
onClick: onToggleTraceDetails,
},
{
@@ -128,7 +127,6 @@ function TraceOptionsMenu({
icon
color="secondary"
aria-label="Trace options"
testId="trace-options-button"
>
<Settings2 size={14} />
</Button>

View File

@@ -1,8 +1,6 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
@@ -178,19 +176,9 @@ describe('TraceDetailsHeader – trace metadata row', () => {
hasMissingSpans: false,
totalSpansCount: 42,
totalErrorSpansCount: 0,
ai: {
tokens: {
input: 12040,
output: 3110,
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
},
totalCost: 0.0421,
},
};
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
const mockSummary = (data?: typeof traceMetadata): void => {
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
};
@@ -213,77 +201,6 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(screen.getByText(duration)).toBeInTheDocument();
});
it('renders AI tokens and cost when the summary has them', () => {
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('omits AI tokens and cost when the summary has no ai field', () => {
mockSummary({ ...traceMetadata, ai: undefined });
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('shows AI tokens when input and output are both zero', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
},
} as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('shows zero for a missing input', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: undefined },
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
});
it('shows zero AI tokens when input and output are both missing', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: {
...traceMetadata.ai.tokens,
input: undefined,
output: undefined,
},
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
});
it('omits the cost when the summary sends null', () => {
mockSummary({
...traceMetadata,
ai: { ...traceMetadata.ai, totalCost: null },
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
mockSummary(traceMetadata);
@@ -316,88 +233,3 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
});
const metadataText = /Missing Span/;
describe('TraceDetailsHeader – tabs', () => {
beforeEach(() => {
jest.mocked(useTraceSummary).mockReturnValue({
data: {
...SUMMARY,
rootServiceEntryPoint: 'Missing Span',
hasMissingSpans: true,
},
isLoading: false,
});
});
afterEach(() => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: false });
});
// In-memory URL state, so a tab switch can't leak into the next test.
const renderHeader = (): void => {
render(
<NuqsTestingAdapter hasMemory>
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />
</NuqsTestingAdapter>,
);
};
it('shows the Overview-only sections on the Overview tab', () => {
renderHeader();
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
expect(screen.getByTestId('trace-analytics-button')).toBeInTheDocument();
expect(screen.getByTestId('trace-options-button')).toBeInTheDocument();
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
});
it('keeps only the metadata row on the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
expect(
screen.queryByTestId('trace-analytics-button'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('trace-options-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-options-button'));
await user.click(await screen.findByTestId('toggle-trace-details'));
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('closes the Analytics panel when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-analytics-button'));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'true',
);
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'false',
);
});
});

View File

@@ -1,505 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import NoData from '../NoData/NoData';
import {
setTraceStoreAvailableColorByFields,
useTraceStore,
} from '../stores/traceStore';
import {
SPAN_DETAILS_PANEL_MIN_WIDTH,
SpanDetailVariant,
} from '../SpanDetailsPanel/constants';
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from '../TraceWaterfall/types';
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from '../utils';
import cx from 'classnames';
import styles from './TraceDetailsOverview.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
interface TraceDetailsOverviewProps {
filteredSpanIds: string[];
isFilterActive: boolean;
onHasTraceDataChange: (hasTraceData: boolean) => void;
dataLoadedFiredForRef: MutableRefObject<string>;
}
function TraceDetailsOverview({
filteredSpanIds,
isFilterActive,
onHasTraceDataChange,
dataLoadedFiredForRef,
}: TraceDetailsOverviewProps): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
Set<string>
>(new Set());
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
const params = getUnstableCurrentSearchParams();
params.delete('spanId');
safeNavigate({ search: params.toString() }, { replace: true });
}, [safeNavigate]);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
dataLoadedFiredForRef,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
const parentIds = new Set(
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
);
setLocalUncollapsedNodes(parentIds);
}
}, [isFullDataLoaded, allSpans]);
// Backend mode: sync uncollapsed state from API response
useEffect(() => {
if (
!isFullDataLoaded &&
traceData &&
traceData.payload &&
traceData.payload.uncollapsedSpans
) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData, isFullDataLoaded]);
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (deepLinkResolvedRef.current) {
return;
}
if (allSpans.length === 0) {
return;
}
if (selectedSpanId) {
const span = allSpans.find((s) => s.span_id === selectedSpanId);
if (!span) {
// Span not in the current window — wait for more data (backend
// pagination) before marking resolved.
return;
}
setSelectedSpan(span);
} else {
setSelectedSpan((prev) => prev ?? allSpans[0]);
}
deepLinkResolvedRef.current = true;
}, [selectedSpanId, allSpans]);
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
// visible. Keyed on URL `spanId`(selectedSpanId).
useEffect(() => {
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
if (ancestors.size === 0) {
return;
}
setLocalUncollapsedNodes((prev) => {
// Check if all ancestors are already expanded — avoid unnecessary state update
let allPresent = true;
for (const id of ancestors) {
if (!prev.has(id)) {
allPresent = false;
break;
}
}
if (allPresent) {
return prev;
}
const next = new Set(prev);
for (const id of ancestors) {
next.add(id);
}
return next;
});
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
const [spanDetailVariant, handleVariantChange] = useSpanDetailVariant();
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const hasTraceData = !showNoData && allSpans.length > 0;
useEffect(() => {
onHasTraceDataChange(hasTraceData);
return (): void => onHasTraceDataChange(false);
}, [hasTraceData, onHasTraceDataChange]);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
return (
<>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</>
);
}
export default TraceDetailsOverview;

View File

@@ -1,5 +0,0 @@
function TraceDetailsThread(): JSX.Element {
return <div data-testid="trace-details-thread" />;
}
export default TraceDetailsThread;

View File

@@ -1,3 +1,10 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.layoutRow {
display: flex;
flex: 1;

View File

@@ -1,6 +0,0 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}

View File

@@ -1,21 +0,0 @@
import { ReactNode } from 'react';
import TraceStoreSync from '../stores/TraceStoreSync';
import styles from './TraceDetailsWrapper.module.scss';
interface TraceDetailsWrapperProps {
children: ReactNode;
}
function TraceDetailsWrapper({
children,
}: TraceDetailsWrapperProps): JSX.Element {
return (
<TraceStoreSync>
<div className={styles.root}>{children}</div>
</TraceStoreSync>
);
}
export default TraceDetailsWrapper;

View File

@@ -30,9 +30,13 @@
// lives here. Shares visual treatment with the waterfall hover popover
// but is positioned `fixed` instead of anchored.
.tooltip {
composes: tooltipSurface from '../shared.module.scss';
position: fixed;
z-index: 1000;
pointer-events: none;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}

View File

@@ -8,7 +8,6 @@ import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
import { DEFAULT_ROW_HEIGHT } from './constants';
import { useCanvasSetup } from './hooks/useCanvasSetup';
import { useCursorTooltipPosition } from './hooks/useCursorTooltipPosition';
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
@@ -201,11 +200,15 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
handleHoverMouseLeave,
]);
const tooltipRef = useCursorTooltipPosition<HTMLDivElement>(tooltipContent);
const tooltipElement = tooltipContent
? createPortal(
<div ref={tooltipRef} className={styles.tooltip}>
<div
className={styles.tooltip}
style={{
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
}}
>
{tooltipContent.event ? (
<EventTooltipContent
eventName={tooltipContent.event.name}

View File

@@ -1,9 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { SpanV3 } from 'types/api/trace/getTraceV3';
@@ -31,6 +30,7 @@ function TraceFlamegraph({
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
const urlQuery = useUrlQuery();
const history = useHistory();
const { search } = useLocation();
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
urlQuery.get('spanId') || '',
);
@@ -42,14 +42,14 @@ function TraceFlamegraph({
const handleSpanClick = useCallback(
(spanId: string): void => {
setFirstSpanAtFetchLevel(spanId);
const searchParams = getUnstableCurrentSearchParams();
const searchParams = new URLSearchParams(search);
//tood: use from query params constants
if (searchParams.get('spanId') !== spanId) {
searchParams.set('spanId', spanId);
history.replace({ search: searchParams.toString() });
}
},
[history],
[history, search],
);
const previewFields = useTraceStore((s) => s.previewFields);

View File

@@ -1,36 +0,0 @@
import { RefObject, useLayoutEffect, useRef } from 'react';
const CURSOR_OFFSET = 15;
interface CursorAnchor {
clientX: number;
clientY: number;
}
function resolveAxis(cursor: number, size: number, viewport: number): number {
const after = cursor + CURSOR_OFFSET;
if (after + size <= viewport) {
return after;
}
return Math.max(0, cursor - CURSOR_OFFSET - size);
}
/** Places a fixed element beside the cursor, flipping sides when it would overflow the viewport. */
export function useCursorTooltipPosition<T extends HTMLElement>(
anchor: CursorAnchor | null,
): RefObject<T> {
const ref = useRef<T>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el || !anchor) {
return;
}
const left = resolveAxis(anchor.clientX, el.offsetWidth, window.innerWidth);
const top = resolveAxis(anchor.clientY, el.offsetHeight, window.innerHeight);
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}, [anchor]);
return ref;
}

View File

@@ -1,47 +0,0 @@
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-2) 0;
border-top: 1px solid;
border-image: linear-gradient(
90deg,
var(--l3-background-transparent),
var(--l3-border),
var(--l3-background-transparent)
)
1;
}
.section:first-child {
padding-top: 0;
border-top: none;
}
.section:last-child {
padding-bottom: 0;
}
.row {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
column-gap: var(--spacing-2);
min-width: 0;
}
.nestedRow {
padding-left: var(--spacing-6);
}
.rowLabel {
min-width: 0;
overflow-wrap: anywhere;
}
.rowValue {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

@@ -1,42 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './TraceTooltip.module.scss';
interface TraceTooltipRowProps {
label: string;
value?: string;
isNested?: boolean;
isHeading?: boolean;
testId?: string;
}
function TraceTooltipRow({
label,
value,
isNested,
isHeading,
testId,
}: TraceTooltipRowProps): JSX.Element {
return (
<div
className={cx(styles.row, isNested && styles.nestedRow)}
data-testid={testId}
>
<Typography.Text
size="small"
color={isHeading ? undefined : 'muted'}
className={styles.rowLabel}
>
{label}
</Typography.Text>
{value !== undefined && (
<Typography.Text size="small" className={styles.rowValue}>
{value}
</Typography.Text>
)}
</div>
);
}
export default TraceTooltipRow;

View File

@@ -1,15 +0,0 @@
import { ReactNode } from 'react';
import styles from './TraceTooltip.module.scss';
interface TraceTooltipSectionProps {
children: ReactNode;
}
function TraceTooltipSection({
children,
}: TraceTooltipSectionProps): JSX.Element {
return <div className={styles.section}>{children}</div>;
}
export default TraceTooltipSection;

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useHistory, useLocation } from 'react-router-dom';
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
@@ -12,7 +12,6 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -164,11 +163,12 @@ function Filters({
const { selectedCategory, categories, handleCategoryChange } =
useSpanCategoryFilter(filterProps);
const { search } = useLocation();
const history = useHistory();
const handlePrevNext = useCallback(
(index: number, spanId?: string): void => {
const searchParams = getUnstableCurrentSearchParams();
const searchParams = new URLSearchParams(search);
if (spanId) {
searchParams.set('spanId', spanId);
} else {
@@ -177,7 +177,7 @@ function Filters({
history.replace({ search: searchParams.toString() });
},
[filteredSpanIds, history],
[filteredSpanIds, history, search],
);
const isFilterActive = committedExpression.trim().length > 0;

View File

@@ -203,9 +203,8 @@
.statusCell {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: center;
cursor: pointer;
padding-left: var(--spacing-2);
--badge-border-radius: 2px;
--badge-padding: 3px 6px;
@@ -395,11 +394,6 @@
}
}
.aiSpanIcon {
flex-shrink: 0;
margin: 0 var(--spacing-2);
}
.treeLabel {
color: var(--l1-foreground);
font-family: 'Inter';

View File

@@ -20,20 +20,15 @@ import {
} from '@tanstack/react-table';
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import {
ChevronDown,
ChevronRight,
Link,
ListPlus,
Sparkle,
} from '@signozhq/icons';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -44,8 +39,6 @@ import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
import { isAiSpan } from '../../../utils/aiTraceUtils';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../types';
@@ -325,23 +318,14 @@ const SpanOverview = memo(function SpanOverview({
</span>
{/* Colored service dot */}
{isAiSpan(span) ? (
<Sparkle
size={10}
color={effectiveColor}
fill={effectiveColor}
className={styles.aiSpanIcon}
/>
) : (
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
)}
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
{/* Span name + service name */}
<span className={styles.treeLabel}>
@@ -676,19 +660,19 @@ function Success(props: ISuccessProps): JSX.Element {
setSelectedSpanToAddToFunnel(span);
}, []);
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const handleSpanClick = useCallback(
(span: SpanV3): void => {
setSelectedSpan(span);
const params = getUnstableCurrentSearchParams();
if (span?.span_id) {
params.set('spanId', span?.span_id);
urlQuery.set('spanId', span?.span_id);
}
safeNavigate({ search: params.toString() }, { replace: true });
safeNavigate({ search: urlQuery.toString() }, { replace: true });
},
[setSelectedSpan, safeNavigate],
[setSelectedSpan, urlQuery, safeNavigate],
);
// Left side columns using TanStack React Table (extensible for future columns)
@@ -960,7 +944,9 @@ function Success(props: ISuccessProps): JSX.Element {
onMouseLeave={(): void => applyHoverClass(null)}
onClick={(): void => handleSpanClick(span)}
>
<SpanHintBadge span={span} />
{span.response_status_code && (
<HttpStatusBadge statusCode={span.response_status_code} />
)}
</div>
);
})}

View File

@@ -1,10 +1,6 @@
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
// Local identity-proxy mock for this module so `styles.foo` resolves to
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
@@ -230,11 +226,6 @@ describe('Span Click User Flows', () => {
jest.clearAllMocks();
// Clear all URL parameters
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
__setSearchParamsGetterForTest(() => new URLSearchParams(mockUrlQuery));
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('clicking span updates URL with spanId parameter', async () => {
@@ -269,6 +260,7 @@ describe('Span Click User Flows', () => {
) as HTMLElement;
await user.click(spanElement);
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringContaining('spanId=span-1'),
@@ -436,6 +428,10 @@ describe('Span Click User Flows', () => {
await user.click(spanElement);
// Verify existing parameters are preserved and spanId is added
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringMatching(
@@ -445,41 +441,4 @@ describe('Span Click User Flows', () => {
{ replace: true },
);
});
it('keeps a tab param the router has not seen when selecting spans', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// nuqs wrote ?tab=thread to the real URL; react-router's copy is stale.
__setSearchParamsGetterForTest(() => new URLSearchParams('tab=thread'));
renderWithTraceProvider(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.span_id)}
isFullDataLoaded={false}
localUncollapsedNodes={new Set()}
setLocalUncollapsedNodes={jest.fn()}
setInterestedSpanId={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
filteredSpanIds={[]}
isFilterActive={false}
/>,
undefined,
{ initialRoute: '/trace' },
);
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
expect(mockSafeNavigate).toHaveBeenCalledWith(
{ search: expect.stringMatching(/tab=thread.*spanId=span-1/) },
{ replace: true },
);
});
});

View File

@@ -53,8 +53,3 @@ export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
);
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
export enum TraceDetailsTab {
Overview = 'overview',
Thread = 'thread',
}

View File

@@ -1,26 +0,0 @@
import { useCallback, useState } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
/** Span details position, persisted under the same key Overview uses. */
export function useSpanDetailVariant(): [
SpanDetailVariant,
(variant: SpanDetailVariant) => void,
] {
const [variant, setVariant] = useState<SpanDetailVariant>(
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
);
const changeVariant = useCallback((next: SpanDetailVariant): void => {
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, next);
setVariant(next);
}, []);
return [variant, changeVariant];
}

View File

@@ -1,15 +0,0 @@
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
import { TraceDetailsTab } from '../constants';
export function useTraceDetailsTab(): UseQueryStateReturn<
TraceDetailsTab,
TraceDetailsTab
> {
return useQueryState(
'tab',
parseAsStringEnum(Object.values(TraceDetailsTab)).withDefault(
TraceDetailsTab.Overview,
),
);
}

View File

@@ -1,21 +1,90 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailsTab } from './constants';
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
import NoData from './NoData/NoData';
import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import {
SPAN_DETAILS_PANEL_MIN_WIDTH,
SpanDetailVariant,
} from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview';
import TraceDetailsThread from './TraceDetailsThread/TraceDetailsThread';
import TraceDetailsWrapper from './TraceDetailsWrapper/TraceDetailsWrapper';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from './utils';
import cx from 'classnames';
import styles from './TraceDetailsV3.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
function TraceDetailsV3(): JSX.Element {
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
Set<string>
>(new Set());
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState(false);
const [hasTraceData, setHasTraceData] = useState(false);
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef<string>('');
const dataLoadedFiredForRef = useRef('');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() }, { replace: true });
}, [urlQuery, safeNavigate]);
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean): void => {
@@ -25,31 +94,421 @@ function TraceDetailsV3(): JSX.Element {
[],
);
// The filter query lives in Filters' local state, which unmounts with the
// Overview tab; drop its result too so both start empty on return.
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
useEffect(() => {
if (!isOverview) {
handleFilteredSpansChange([], false);
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
}
}, [isOverview, handleFilteredSpansChange]);
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
const parentIds = new Set(
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
);
setLocalUncollapsedNodes(parentIds);
}
}, [isFullDataLoaded, allSpans]);
// Backend mode: sync uncollapsed state from API response
useEffect(() => {
if (
!isFullDataLoaded &&
traceData &&
traceData.payload &&
traceData.payload.uncollapsedSpans
) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData, isFullDataLoaded]);
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (deepLinkResolvedRef.current) {
return;
}
if (allSpans.length === 0) {
return;
}
if (selectedSpanId) {
const span = allSpans.find((s) => s.span_id === selectedSpanId);
if (!span) {
// Span not in the current window — wait for more data (backend
// pagination) before marking resolved.
return;
}
setSelectedSpan(span);
} else {
setSelectedSpan((prev) => prev ?? allSpans[0]);
}
deepLinkResolvedRef.current = true;
}, [selectedSpanId, allSpans]);
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
// visible. Keyed on URL `spanId`(selectedSpanId).
useEffect(() => {
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
if (ancestors.size === 0) {
return;
}
setLocalUncollapsedNodes((prev) => {
// Check if all ancestors are already expanded — avoid unnecessary state update
let allPresent = true;
for (const id of ancestors) {
if (!prev.has(id)) {
allPresent = false;
break;
}
}
if (allPresent) {
return prev;
}
const next = new Set(prev);
for (const id of ancestors) {
next.add(id);
}
return next;
});
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
);
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const handleVariantChange = useCallback(
(newVariant: SpanDetailVariant): void => {
setLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
newVariant,
);
setSpanDetailVariant(newVariant);
},
[],
);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
return (
<TraceDetailsWrapper>
<TraceDetailsHeader
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={hasTraceData}
/>
{isOverview ? (
<TraceDetailsOverview
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
onHasTraceDataChange={setHasTraceData}
dataLoadedFiredForRef={dataLoadedFiredForRef}
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={
!!traceData?.payload?.spans?.length && !showNoData
}
/>
) : (
<TraceDetailsThread />
)}
</TraceDetailsWrapper>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall')
? waterfallChildren
: null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</div>
</TraceStoreSync>
);
}

View File

@@ -1,20 +0,0 @@
.tooltipBody {
display: flex;
flex-direction: column;
letter-spacing: var(--paragraph-small-400-letter-spacing);
color: var(--l1-foreground-hover);
}
.tooltipTitle {
font-family: 'SF Mono', 'Geist Mono', monospace;
word-break: break-all;
}
.tooltipSurface {
background-color: color-mix(in srgb, var(--l3-background) 60%, transparent);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: var(--spacing-4);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}

View File

@@ -4,28 +4,36 @@ import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { useAppContext } from 'providers/App/App';
import {
setTraceStoreAvailableColorByFields,
setTraceStoreCallbacks,
setTraceStoreUserPreferences,
} from './traceStore';
interface TraceStoreSyncProps {
availableColorByFields: string[] | undefined;
children: ReactNode;
}
/**
* Bridges React-managed inputs (`userPreferences` from AppContext and the
* user-pref mutation hook) into the Zustand store. `availableColorByFields`
* is pushed by TraceDetailsOverview, which owns the span data.
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
* from AppContext, and the user-pref mutation hook) into the Zustand store.
*
* Renders nothing until `userPreferences` resolves so the flamegraph never
* paints with the default colour first and then swaps to the user's
* persisted choice. AppProvider fires the prefs query as soon as the user
* is logged in, so this gate is usually already settled by mount time.
*/
function TraceStoreSync({ children }: TraceStoreSyncProps): JSX.Element | null {
function TraceStoreSync({
availableColorByFields,
children,
}: TraceStoreSyncProps): JSX.Element | null {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
useEffect(() => {
setTraceStoreUserPreferences(userPreferences ?? null);
}, [userPreferences]);

View File

@@ -37,7 +37,7 @@ interface TraceStoreState {
availableColorByOptions: ColorByOption[];
previewFields: TelemetryFieldKey[];
// --- Setters used only by TraceStoreSync and TraceDetailsOverview ---
// --- Setters used only by TraceStoreSync ---
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
setCallbacks: (callbacks: {

View File

@@ -46,8 +46,6 @@ import {
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
TRACE_SCENARIOS,
type TraceScenario,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -105,13 +103,6 @@ const spanLogScopeOf = (body: QueryRangeRequestV5): SpanLogScope => {
export const traceDetailsMocks = defineStoryMocks({
controls: {
scenario: choiceControl<TraceScenario>('Scenario', {
group: TRACE,
description:
'The spans the trace is built from: a checkout, one span per HTTP status badge, or an agent run with LLM, tool and agent spans and the AI totals in the header.',
options: TRACE_SCENARIOS,
value: 'checkout',
}),
spans: countControl('Spans', {
group: TRACE,
description:
@@ -160,7 +151,6 @@ export const traceDetailsMocks = defineStoryMocks({
},
handlers: (values, response) => {
const trace = {
scenario: values.scenario,
spans: values.spans,
errors: values.errors,
traceStart: traceStartOf(),
@@ -298,7 +288,7 @@ export const traceDetailsMocks = defineStoryMocks({
},
config: (values) => ({
route: values.spanPanel
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.spans))}`
: `/trace/${STORY_TRACE_ID}`,
}),
effect: (values) => {

View File

@@ -13,12 +13,7 @@ import {
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
focusSpanIndex,
scenarioSpanCount,
spanIdAt,
STORY_TRACE_ID,
} from './__story_mockdata__/traceDetails';
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
import {
openRowMenu,
startDownload,
@@ -101,40 +96,6 @@ export const BottomStripLoading: Story = {
args: { bottomStrip: true, dataState: 'loading' },
};
/**
* One span per HTTP status badge the waterfall draws: informational, success,
* redirect, client and server errors, and a span with no status code and so no
* badge.
*/
export const StatusCodes: Story = {
args: {
scenario: 'status-codes',
spans: scenarioSpanCount('status-codes'),
spanPanel: false,
},
};
const AI_SPANS = scenarioSpanCount('ai');
/**
* An agent run: LLM, tool and agent badges with sparkle icons in the waterfall,
* token and cost totals in the header, the span panel on an LLM call with its
* tokens and cost, and that span's hover card open on its usage breakdown.
*/
export const AiTrace: Story = {
args: { scenario: 'ai', spans: AI_SPANS },
play: async ({ canvasElement }): Promise<void> => {
const row = await within(canvasElement).findByTestId(
`cell-0-${spanIdAt(focusSpanIndex('ai', AI_SPANS))}`,
undefined,
untilLoaded,
);
await fireEvent.mouseOver(row);
await screen.findByText('Usage Breakdown', undefined, untilLoaded);
},
};
/**
* Turns on Highlight errors, which writes `has_error = true` into the filter and
* expands the bar to show it.

View File

@@ -23,10 +23,6 @@ interface SpanTemplate {
name: string;
kind: string;
failing?: boolean;
/** The span the panel opens on when nothing fails. */
focus?: boolean;
/** `response_status_code`; failing spans default to 503, the rest to 200. */
statusCode?: string;
attributes: Record<string, string | number | boolean>;
}
@@ -127,145 +123,6 @@ const SPAN_TEMPLATES: SpanTemplate[] = [
},
];
/** One span per badge colour, plus a span with no status code and so no badge. */
const STATUS_CODE_TEMPLATES: SpanTemplate[] = [
['200', 'GET /status/ok'],
['101', 'GET /ws/upgrade'],
['201', 'POST /status/created'],
['204', 'DELETE /status/no-content'],
['301', 'GET /status/moved'],
['304', 'GET /status/not-modified'],
['400', 'POST /status/bad-request'],
['401', 'GET /status/unauthorized'],
['404', 'GET /status/not-found'],
['429', 'GET /status/rate-limited'],
['500', 'GET /status/internal-error'],
['502', 'GET /status/bad-gateway'],
['503', 'GET /status/unavailable'],
['504', 'GET /status/timeout'],
['', 'internal.compute'],
].map(
([statusCode, name]): SpanTemplate => ({
service: 'status-api',
name,
kind: statusCode ? 'Server' : 'Internal',
failing: Number(statusCode) >= 500,
statusCode,
attributes: statusCode
? {
'http.method': name.split(' ')[0],
'http.status_code': Number(statusCode),
}
: {},
}),
);
/**
* An agent run: agent, LLM and tool spans, each badged by kind. The LLM spans
* cover cache counted inside input (OpenAI), cache counted beside it
* (Anthropic), reasoning, and an embedding with input only.
*/
const AI_TEMPLATES: SpanTemplate[] = [
{
service: 'support-agent',
name: 'invoke_agent support-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'support-agent' },
},
{
service: 'support-agent',
name: 'chat gpt-4o',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'gpt-4o-2024-08-06',
'gen_ai.usage.input_tokens': 1840,
'gen_ai.usage.output_tokens': 212,
'gen_ai.usage.cache_read.input_tokens': 1024,
'gen_ai.usage.cache_creation.input_tokens': 0,
'signoz.gen_ai.usage.tokens.cost': 0.00566,
},
},
{
service: 'support-agent',
name: 'execute_tool search_docs',
kind: 'Internal',
attributes: { 'gen_ai.tool.name': 'search_docs' },
},
{
service: 'support-agent',
name: 'chat claude-sonnet-4',
kind: 'Client',
focus: true,
attributes: {
'gen_ai.request.model': 'claude-sonnet-4-20250514',
'gen_ai.usage.input_tokens': 86,
'gen_ai.usage.output_tokens': 640,
'gen_ai.usage.cache_read.input_tokens': 4200,
'gen_ai.usage.cache_creation.input_tokens': 1300,
'signoz.gen_ai.usage.tokens.cost': 0.01853,
},
},
{
service: 'docs-api',
name: 'GET /docs/search',
kind: 'Server',
attributes: {
'http.method': 'GET',
'http.route': '/docs/search',
'http.status_code': 200,
},
},
{
service: 'support-agent',
name: 'execute_tool fetch_ticket',
kind: 'Internal',
failing: true,
attributes: { 'gen_ai.tool.name': 'fetch_ticket' },
},
{
service: 'support-agent',
name: 'invoke_agent triage-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'triage-agent' },
},
{
service: 'support-agent',
name: 'embeddings text-embedding-3-small',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'text-embedding-3-small',
'gen_ai.usage.input_tokens': 312,
'signoz.gen_ai.usage.tokens.cost': 0.00000624,
},
},
{
service: 'support-agent',
name: 'chat o3-mini',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'o3-mini-2025-01-31',
'gen_ai.usage.input_tokens': 920,
'gen_ai.usage.output_tokens': 1480,
'gen_ai.usage.reasoning.output_tokens': 1152,
'signoz.gen_ai.usage.tokens.cost': 0.007524,
},
},
];
export const TRACE_SCENARIOS = ['checkout', 'status-codes', 'ai'] as const;
export type TraceScenario = (typeof TRACE_SCENARIOS)[number];
const SCENARIO_TEMPLATES: Record<TraceScenario, SpanTemplate[]> = {
checkout: SPAN_TEMPLATES,
'status-codes': STATUS_CODE_TEMPLATES,
ai: AI_TEMPLATES,
};
/** Spans it takes for the scenario to show every template once. */
export const scenarioSpanCount = (scenario: TraceScenario): number =>
SCENARIO_TEMPLATES[scenario].length;
const TRACE_SERVICES = [
...new Set(SPAN_TEMPLATES.map(({ service }) => service)),
];
@@ -289,7 +146,6 @@ interface BuiltSpan {
}
interface TraceOptions {
scenario: TraceScenario;
spans: number;
errors: boolean;
/** Epoch milliseconds the root span starts at. */
@@ -300,33 +156,24 @@ export const spanIdAt = (index: number): string =>
`b17e${index.toString(16).padStart(12, '0')}`;
/**
* The span the story opens the panel on: the scenario's focus span, else the
* first failing one, so its events and error styling are on screen, and the
* root when neither is in the trace.
* The span the story opens the panel on: the first failing one, so its events
* and error styling are on screen, and the root when nothing fails.
*/
export const focusSpanIndex = (
scenario: TraceScenario,
spans: number,
): number => {
const templates = SCENARIO_TEMPLATES[scenario];
const focus = templates.findIndex((template) => template.focus);
const failing = templates.findIndex((template) => template.failing);
const index = focus >= 0 ? focus : failing;
export const focusSpanIndex = (spans: number): number => {
const failing = SPAN_TEMPLATES.findIndex(({ failing }) => failing);
return index >= 0 && index < spans ? index : 0;
return failing >= 0 && failing < spans ? failing : 0;
};
const buildSpans = ({
scenario,
spans,
errors,
traceStart,
}: TraceOptions): BuiltSpan[] => {
const built: BuiltSpan[] = [];
const templates = SCENARIO_TEMPLATES[scenario];
for (let index = 0; index < spans; index += 1) {
const template = templates[index % templates.length];
const template = SPAN_TEMPLATES[index % SPAN_TEMPLATES.length];
const parent = index === 0 ? undefined : built[parentIndexOf(index)];
// Children fit inside their parent's window, second child after the first,
// so no span outlives its parent and no two siblings overlap.
@@ -410,8 +257,7 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
status_code: span.hasError ? 2 : 1,
status_code_string: span.hasError ? 'Error' : 'Ok',
status_message: span.hasError ? 'upstream unavailable' : '',
response_status_code:
span.template.statusCode ?? (span.hasError ? '503' : '200'),
response_status_code: span.hasError ? '503' : '200',
has_children: span.hasChildren,
sub_tree_node_count: span.subTreeNodeCount,
level: span.level,
@@ -458,36 +304,6 @@ export const traceWaterfallResponse = (
};
};
const numberAttribute = (span: BuiltSpan, key: string): number =>
Number(span.template.attributes[key] ?? 0);
/** The trace's AI totals, summed over its spans the way the backend does. */
const traceAiSummary = (
spans: BuiltSpan[],
): GetTraceSummary200['data']['ai'] => {
const aiSpans = spans.filter(({ template }) =>
Object.keys(template.attributes).some((key) => key.includes('gen_ai.')),
);
if (aiSpans.length === 0) {
return undefined;
}
const sum = (key: string): number =>
aiSpans.reduce((total, span) => total + numberAttribute(span, key), 0);
return {
tokens: {
input: sum('gen_ai.usage.input_tokens'),
output: sum('gen_ai.usage.output_tokens'),
cacheRead: sum('gen_ai.usage.cache_read.input_tokens'),
cacheWrite: sum('gen_ai.usage.cache_creation.input_tokens'),
reasoning: sum('gen_ai.usage.reasoning.output_tokens'),
},
totalCost: sum('signoz.gen_ai.usage.tokens.cost'),
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
@@ -505,7 +321,6 @@ export const traceSummaryResponse = (
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
ai: traceAiSummary(spans),
},
};
};
@@ -657,7 +472,7 @@ export const spanLogRows = (
): RawRow[] => {
const step = ROOT_DURATION_MS / Math.max(count, 1);
const spans = buildSpans(options);
const focus = spans[focusSpanIndex(options.scenario, options.spans)];
const focus = spans[focusSpanIndex(options.spans)];
return Array.from({ length: count }, (_, index) => {
const at = options.traceStart + SCOPE_OFFSET_MS[scope] + index * step;

View File

@@ -9,14 +9,9 @@ import {
RESERVED_ERROR,
} from './utils/generateColorPair';
/** Like `||`, but keeps `0`. */
const valueOrUndefined = <T>(value: T): T | undefined =>
value || value === 0 ? value : undefined;
/**
* Look up an attribute from both `resource` and `attributes` on a span.
* Resources are checked first (service.name, k8s.* etc. live there).
* Falsy values count as missing, except `0`.
*
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
* structurally — the only fields touched are `resource` and `attributes`.
@@ -27,14 +22,11 @@ export function getSpanAttribute(
span: {
resource?: Record<string, string>;
attributes?: Record<string, any>;
tagMap?: Record<string, any>;
},
key: string,
): string | undefined {
return (
valueOrUndefined(span.resource?.[key]) ??
valueOrUndefined(span.attributes?.[key]) ??
valueOrUndefined(span.tagMap?.[key])
span.resource?.[key] || span.attributes?.[key] || (span as any).tagMap?.[key]
);
}

View File

@@ -1,51 +0,0 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import {
AiSpanKind,
getAiSpanKind,
getTotalInputTokens,
isAiSpan,
} from '../aiTraceUtils';
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
describe('getAiSpanKind', () => {
it.each<[Record<string, string | number>, AiSpanKind]>([
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
[
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
AiSpanKind.Tool,
],
[
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
AiSpanKind.Agent,
],
])('classifies %p as %s', (attributes, kind) => {
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
});
it('returns undefined for non-AI spans and empty values', () => {
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
expect(isAiSpan(spanWith({}))).toBe(false);
});
});
describe('getTotalInputTokens', () => {
it.each([
// Cache reported inside input (OpenAI): input already includes it.
[{ input: 1000, cacheRead: 600, cacheWrite: 100 }, 1000],
// Cache reported separately (Anthropic): add it.
[{ input: 50, cacheRead: 600, cacheWrite: 100 }, 750],
[{ input: 71, cacheRead: 0, cacheWrite: 0 }, 71],
[{ input: 10 }, 10],
[{ cacheRead: 600 }, 600],
])('%p → %p', (tokens, expected) => {
expect(getTotalInputTokens(tokens)).toBe(expected);
});
});

View File

@@ -1,168 +0,0 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
export const GEN_AI_KEYS = {
requestModel: 'gen_ai.request.model',
toolName: 'gen_ai.tool.name',
agentName: 'gen_ai.agent.name',
inputTokens: 'gen_ai.usage.input_tokens',
outputTokens: 'gen_ai.usage.output_tokens',
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
cost: 'signoz.gen_ai.usage.tokens.cost',
} as const;
export enum AiSpanKind {
Llm = 'llm',
Tool = 'tool',
Agent = 'agent',
}
const LLM_KEYS = [
GEN_AI_KEYS.requestModel,
GEN_AI_KEYS.inputTokens,
GEN_AI_KEYS.outputTokens,
GEN_AI_KEYS.cacheReadTokens,
GEN_AI_KEYS.cacheCreationTokens,
GEN_AI_KEYS.reasoningTokens,
];
function hasAttribute(span: SpanV3, key: string): boolean {
return getSpanAttribute(span, key) !== undefined;
}
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
return AiSpanKind.Tool;
}
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
return AiSpanKind.Agent;
}
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
return AiSpanKind.Llm;
}
return undefined;
}
export function isAiSpan(span: SpanV3): boolean {
return getAiSpanKind(span) !== undefined;
}
/** Token counts; `null` or missing means the backend did not send the value. */
export interface AiTokenCounts {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
reasoning?: number;
}
export function hasValue(value: number | null | undefined): value is number {
return value !== undefined && value !== null;
}
/**
* Input including cache read/write, counted once. Providers that report cache
* inside `input` (OpenAI) have `input >= cache`; those that report it
* separately (Anthropic) usually have `input < cache`.
*/
export function getTotalInputTokens({
input,
cacheRead,
cacheWrite,
}: AiTokenCounts): number {
const inputValue = input ?? 0;
const cache = (cacheRead ?? 0) + (cacheWrite ?? 0);
return inputValue < cache ? inputValue + cache : inputValue;
}
/** `Input Token` for a count of 1, `Input Tokens` otherwise. */
export function getTokenLabel(
prefix: string,
count: number | null | undefined,
): string {
return `${prefix} ${count === 1 ? 'Token' : 'Tokens'}`;
}
/** Missing values render as `0`. */
export function formatTokens(value: number | null | undefined): string {
return hasValue(value) ? value.toLocaleString('en-US') : '0';
}
export function formatCost(value: number): string {
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
}
export interface SpanAiUsage {
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
reasoningTokens?: number;
cost?: number;
}
export interface SpanAiDetails {
model?: string;
toolName?: string;
agentName?: string;
usage?: SpanAiUsage;
}
function getGenAiAttributeAsString(
span: SpanV3,
key: string,
): string | undefined {
const value = getSpanAttribute(span, key);
return value === undefined ? undefined : String(value);
}
function getGenAiAttributeAsNumber(
span: SpanV3,
key: string,
): number | undefined {
const value = getSpanAttribute(span, key);
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
export function getAIUsageDetails(span: SpanV3): SpanAiDetails | undefined {
const usage: SpanAiUsage = {
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
cacheCreationTokens: getGenAiAttributeAsNumber(
span,
GEN_AI_KEYS.cacheCreationTokens,
),
reasoningTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.reasoningTokens),
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
};
const hasUsage = Object.values(usage).some((value) => value !== undefined);
const details: SpanAiDetails = {
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
usage: hasUsage ? usage : undefined,
};
return details.model || details.toolName || details.agentName || hasUsage
? details
: undefined;
}
export function convertToAiTokens(usage: SpanAiUsage): AiTokenCounts {
return {
input: usage.inputTokens,
output: usage.outputTokens,
cacheRead: usage.cacheReadTokens,
cacheWrite: usage.cacheCreationTokens,
reasoning: usage.reasoningTokens,
};
}

View File

@@ -0,0 +1,48 @@
/* eslint-disable no-empty */
import getLocalStorageKey from 'api/browser/localstorage/get';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultSelectedColumns } from 'container/LLMObservability/Explorer/ListView/configs';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
const isValidTraceColumn = (col: {
name?: string;
signal?: string;
[key: string]: unknown;
}): boolean => !(col?.signal && col.signal !== 'traces');
// --- AI OBSERVABILITY preferences loader config ---
const aiObservabilityLoaders = {
local: (): {
columns: BaseAutocompleteData[];
} => {
const local = getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS);
if (local) {
try {
const parsed = JSON.parse(local);
const localColumns = parsed.selectColumns || [];
return { columns: localColumns.filter(isValidTraceColumn) };
} catch {}
}
return { columns: [] };
},
url: (): {
columns: BaseAutocompleteData[];
} => {
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(urlParams.get('options') || '{}');
const urlColumns = options.selectColumns || [];
return { columns: urlColumns.filter(isValidTraceColumn) };
} catch {}
return { columns: [] };
},
default: (): {
columns: TelemetryFieldKey[];
} => ({
columns: defaultSelectedColumns,
}),
priority: ['local', 'url', 'default'] as const,
};
export default aiObservabilityLoaders;

View File

@@ -0,0 +1,55 @@
/* eslint-disable no-empty */
import getLocalStorageKey from 'api/browser/localstorage/get';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { AI_OBSERVABILITY_TRACE_VIEW_URL_OPTIONS } from '../types';
const isValidTraceColumn = (col: {
name?: string;
signal?: string;
[key: string]: unknown;
}): boolean => !(col?.signal && col.signal !== 'traces');
// --- AI OBSERVABILITY TRACE VIEW preferences loader config ---
// Empty columns mean "use the defaults": they depend on the field keys the view fetches.
const aiObservabilityTraceViewLoaders = {
local: (): {
columns: BaseAutocompleteData[];
} => {
const local = getLocalStorageKey(
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_OPTIONS,
);
if (local) {
try {
const parsed = JSON.parse(local);
const localColumns = parsed.selectColumns || [];
return { columns: localColumns.filter(isValidTraceColumn) };
} catch {}
}
return { columns: [] };
},
url: (): {
columns: BaseAutocompleteData[];
} => {
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(
urlParams.get(AI_OBSERVABILITY_TRACE_VIEW_URL_OPTIONS) || '{}',
);
const urlColumns = options.selectColumns || [];
return { columns: urlColumns.filter(isValidTraceColumn) };
} catch {}
return { columns: [] };
},
default: (): {
columns: TelemetryFieldKey[];
} => ({
columns: [],
}),
// A shared link opens with the sender's columns.
priority: ['url', 'local', 'default'] as const,
};
export default aiObservabilityTraceViewLoaders;

View File

@@ -0,0 +1,44 @@
import { Dispatch, SetStateAction } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultOptionsQuery } from 'container/OptionsMenu/constants';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { PreferenceMode, Preferences } from '../types';
// --- AI OBSERVABILITY TRACE VIEW preferences updater config ---
const getAIObservabilityTraceViewUpdaterConfig = (
redirectWithOptionsData: (options: OptionsQuery) => void,
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
): {
updateColumns: (newColumns: TelemetryFieldKey[], mode: string) => void;
updateFormatting: () => void;
} => ({
updateColumns: (newColumns: TelemetryFieldKey[], mode: string): void => {
if (mode === PreferenceMode.SAVED_VIEW) {
setSavedViewPreferences({ columns: newColumns });
}
if (mode === PreferenceMode.DIRECT) {
redirectWithOptionsData({
...defaultOptionsQuery,
selectColumns: newColumns,
});
const local = JSON.parse(
getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_OPTIONS) ||
'{}',
);
local.selectColumns = newColumns;
setLocalStorageKey(
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_OPTIONS,
JSON.stringify(local),
);
}
},
updateFormatting: (): void => {}, // no-op for AI observability
});
export default getAIObservabilityTraceViewUpdaterConfig;

View File

@@ -0,0 +1,43 @@
import { Dispatch, SetStateAction } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultOptionsQuery } from 'container/OptionsMenu/constants';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { PreferenceMode, Preferences } from '../types';
// --- AI OBSERVABILITY preferences updater config ---
const getAIObservabilityUpdaterConfig = (
redirectWithOptionsData: (options: OptionsQuery) => void,
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
): {
updateColumns: (newColumns: TelemetryFieldKey[], mode: string) => void;
updateFormatting: () => void;
} => ({
updateColumns: (newColumns: TelemetryFieldKey[], mode: string): void => {
if (mode === PreferenceMode.SAVED_VIEW) {
setSavedViewPreferences({ columns: newColumns });
}
if (mode === PreferenceMode.DIRECT) {
redirectWithOptionsData({
...defaultOptionsQuery,
selectColumns: newColumns,
});
const local = JSON.parse(
getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS) || '{}',
);
local.selectColumns = newColumns;
setLocalStorageKey(
LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS,
JSON.stringify(local),
);
}
},
updateFormatting: (): void => {}, // no-op for AI observability
});
export default getAIObservabilityUpdaterConfig;

View File

@@ -7,6 +7,8 @@ import React, {
} from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import {
AI_OBSERVABILITY_PREFERENCE_SOURCE,
AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
PreferenceContextValue,
PreferenceMode,
} from 'providers/preferences/types';
@@ -47,12 +49,26 @@ export function PreferenceContextProvider({
dataSource: DataSource.TRACES,
});
const aiObservabilitySlice = usePreferenceSync({
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
savedViewId: savedViewId || undefined,
dataSource: AI_OBSERVABILITY_PREFERENCE_SOURCE,
});
const aiObservabilityTraceViewSlice = usePreferenceSync({
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
savedViewId: savedViewId || undefined,
dataSource: AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
});
const value = useMemo<PreferenceContextValue>(
() => ({
logs: logsSlice,
traces: tracesSlice,
aiObservability: aiObservabilitySlice,
aiObservabilityTraceView: aiObservabilityTraceViewSlice,
}),
[logsSlice, tracesSlice],
[logsSlice, tracesSlice, aiObservabilitySlice, aiObservabilityTraceViewSlice],
);
return (

View File

@@ -4,9 +4,17 @@ import { ensureLogsRequiredColumns } from 'container/OptionsMenu/constants';
import { has } from 'lodash-es';
import { DataSource } from 'types/common/queryBuilder';
import aiObservabilityLoaderConfig from '../configs/aiObservabilityLoaderConfig';
import aiObservabilityTraceViewLoaderConfig from '../configs/aiObservabilityTraceViewLoaderConfig';
import logsLoaderConfig from '../configs/logsLoaderConfig';
import tracesLoaderConfig from '../configs/tracesLoaderConfig';
import { FormattingOptions, Preferences } from '../types';
import {
AI_OBSERVABILITY_PREFERENCE_SOURCE,
AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
FormattingOptions,
Preferences,
PreferenceSource,
} from '../types';
const migrateColumns = (columns: any): any =>
columns.map((column: any) => {
@@ -65,12 +73,24 @@ function tracesPreferencesLoader(): {
return preferencesLoader(tracesLoaderConfig);
}
function aiObservabilityPreferencesLoader(): {
columns: TelemetryFieldKey[];
} {
return preferencesLoader(aiObservabilityLoaderConfig);
}
function aiObservabilityTraceViewPreferencesLoader(): {
columns: TelemetryFieldKey[];
} {
return preferencesLoader(aiObservabilityTraceViewLoaderConfig);
}
export function usePreferenceLoader({
dataSource,
reSync,
setReSync,
}: {
dataSource: DataSource;
dataSource: PreferenceSource;
reSync: boolean;
setReSync: (value: boolean) => void;
}): {
@@ -87,6 +107,14 @@ export function usePreferenceLoader({
const { columns } = tracesPreferencesLoader();
return { columns };
}
if (dataSource === AI_OBSERVABILITY_PREFERENCE_SOURCE) {
const { columns } = aiObservabilityPreferencesLoader();
return { columns };
}
if (dataSource === AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE) {
const { columns } = aiObservabilityTraceViewPreferencesLoader();
return { columns };
}
return null;
});
const [error, setError] = useState<Error | null>(null);
@@ -105,6 +133,16 @@ export function usePreferenceLoader({
const { columns } = tracesPreferencesLoader();
setPreferences({ columns });
}
if (dataSource === AI_OBSERVABILITY_PREFERENCE_SOURCE) {
const { columns } = aiObservabilityPreferencesLoader();
setPreferences({ columns });
}
if (dataSource === AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE) {
const { columns } = aiObservabilityTraceViewPreferencesLoader();
setPreferences({ columns });
}
} catch (e) {
setError(e as Error);
} finally {

View File

@@ -3,10 +3,14 @@ import { useEffect, useRef, useState } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { TelemetryFieldKey } from 'api/v5/v5';
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
import { DataSource } from 'types/common/queryBuilder';
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
import { FormattingOptions, PreferenceMode, Preferences } from '../types';
import {
FormattingOptions,
PreferenceMode,
Preferences,
PreferenceSource,
} from '../types';
import { usePreferenceUpdater } from '../updater/usePreferenceUpdater';
export function usePreferenceSync({
@@ -15,7 +19,7 @@ export function usePreferenceSync({
savedViewId,
}: {
mode: PreferenceMode;
dataSource: DataSource;
dataSource: PreferenceSource;
savedViewId: string | undefined;
}): {
preferences: Preferences | null;

View File

@@ -1,6 +1,20 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize } from 'container/OptionsMenu/types';
import { DataSource } from 'types/common/queryBuilder';
// AI Observability queries run on traces, so its slice needs a key of its own.
export const AI_OBSERVABILITY_PREFERENCE_SOURCE = 'ai_observability';
// The trace view shares a page with the list view, so it keeps its columns under a URL param of its own.
export const AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE =
'ai_observability_trace_view';
export const AI_OBSERVABILITY_TRACE_VIEW_URL_OPTIONS = 'traceViewOptions';
export type PreferenceSource =
| DataSource
| typeof AI_OBSERVABILITY_PREFERENCE_SOURCE
| typeof AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE;
export enum PreferenceMode {
SAVED_VIEW = 'savedView',
@@ -18,6 +32,8 @@ export interface PreferenceSlice {
export interface PreferenceContextValue {
logs: PreferenceSlice;
traces: PreferenceSlice;
aiObservability: PreferenceSlice;
aiObservabilityTraceView: PreferenceSlice;
}
export interface FormattingOptions {

View File

@@ -8,9 +8,18 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { DataSource } from 'types/common/queryBuilder';
import getAIObservabilityTraceViewUpdaterConfig from '../configs/aiObservabilityTraceViewUpdaterConfig';
import getAIObservabilityUpdaterConfig from '../configs/aiObservabilityUpdaterConfig';
import getLogsUpdaterConfig from '../configs/logsUpdaterConfig';
import getTracesUpdaterConfig from '../configs/tracesUpdaterConfig';
import { FormattingOptions, Preferences } from '../types';
import {
AI_OBSERVABILITY_PREFERENCE_SOURCE,
AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE,
AI_OBSERVABILITY_TRACE_VIEW_URL_OPTIONS,
FormattingOptions,
Preferences,
PreferenceSource,
} from '../types';
const metricsUpdater = {
updateColumns: (): void => {}, // no-op for metrics
@@ -22,7 +31,7 @@ const getUpdaterConfig = (
redirectWithOptionsData: (options: OptionsQuery) => void,
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
): Record<
DataSource,
PreferenceSource,
{
updateColumns: (newColumns: TelemetryFieldKey[], mode: string) => void;
updateFormatting: (newFormatting: FormattingOptions, mode: string) => void;
@@ -38,8 +47,22 @@ const getUpdaterConfig = (
setSavedViewPreferences,
),
[DataSource.METRICS]: metricsUpdater,
[AI_OBSERVABILITY_PREFERENCE_SOURCE]: getAIObservabilityUpdaterConfig(
redirectWithOptionsData,
setSavedViewPreferences,
),
[AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE]:
getAIObservabilityTraceViewUpdaterConfig(
redirectWithOptionsData,
setSavedViewPreferences,
),
});
const getUrlOptionsKey = (dataSource: PreferenceSource): string =>
dataSource === AI_OBSERVABILITY_TRACE_VIEW_PREFERENCE_SOURCE
? AI_OBSERVABILITY_TRACE_VIEW_URL_OPTIONS
: URL_OPTIONS;
export function usePreferenceUpdater({
dataSource,
mode,
@@ -47,7 +70,7 @@ export function usePreferenceUpdater({
setReSync,
setSavedViewPreferences,
}: {
dataSource: DataSource;
dataSource: PreferenceSource;
mode: string;
preferences: Preferences | null;
setReSync: Dispatch<SetStateAction<boolean>>;
@@ -57,7 +80,10 @@ export function usePreferenceUpdater({
updateFormatting: (newFormatting: FormattingOptions) => void;
} {
const { redirectWithQuery: redirectWithOptionsData } =
useUrlQueryData<OptionsQuery>(URL_OPTIONS, defaultOptionsQuery);
useUrlQueryData<OptionsQuery>(
getUrlOptionsKey(dataSource),
defaultOptionsQuery,
);
const updater = getUpdaterConfig(
preferences,
redirectWithOptionsData,

View File

@@ -56,18 +56,12 @@ test.describe('Trace details — flamegraph', () => {
}) => {
await hoverFlamegraphSpan(page, trace.landmarks.errors[0]);
// The status row only renders in the hover card (not in waterfall rows),
// "status: error" only renders in the hover card (not in waterfall rows),
// so it proves both that the card opened and that we hovered the right
// (error) span — the bar was targeted by id via the span→rect map.
await expect(page.getByTestId('span-hover-card-status')).toHaveText(
/status\s*error/,
);
await expect(page.getByTestId('span-hover-card-start')).toHaveText(
/start\s*[\d.]+ ms/,
);
await expect(page.getByTestId('span-hover-card-duration')).toHaveText(
/duration\s*[\d.]+/,
);
await expect(page.getByText('status: error')).toBeVisible();
await expect(page.getByText(/start: [\d.]+ ms/)).toBeVisible();
await expect(page.getByText(/duration: [\d.]+/)).toBeVisible();
});
test('TC-02 clicking a bar selects the span, opens the drawer, and syncs the waterfall row', async ({