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
29 changed files with 848 additions and 180 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

@@ -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,