Compare commits

...

4 Commits

Author SHA1 Message Date
Gaurav Tewari
88539fd51e Merge remote-tracking branch 'origin/main' into feat/saved-view-poc 2026-10-28 02:04:38 +05:30
Gaurav Tewari
338d4317c7 feat: make save view work 2026-10-28 02:04:37 +05:30
Gaurav Tewari
65acd62a9d feat: add AI preference framework for list and trace view columns 2026-10-28 02:03:09 +05:30
Gaurav Tewari
94339f3132 feat: add column options to the AI list view 2026-10-27 23:32:55 +05:30
21 changed files with 647 additions and 128 deletions

View File

@@ -14,6 +14,8 @@ export enum LOCALSTORAGE {
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_OPTIONS = 'AI_OBSERVABILITY_TRACE_OPTIONS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

View File

@@ -3,7 +3,7 @@ import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
@@ -191,6 +191,7 @@ function Explorer(): JSX.Element {
testId="llm-observability-explorer"
showFilters={isOpen}
onToggleFilters={(): void => setOpen(!isOpen)}
savedViewProps={{ source: SavedviewtypesSourceDTO.ai_observability }}
quickFilterProps={{
className: 'qf-traces-explorer',
quickFilterSource: QuickFiltersSource.AI_OBSERVABILITY,
@@ -225,11 +226,11 @@ function Explorer(): JSX.Element {
}
/>
</div>
<ExplorerCard sourcepage={DataSource.TRACES}>
<div>
<div className="query-section-container">
<QuerySection />
</div>
</ExplorerCard>
</div>
<div className="traces-explorer-views">
{isCancelled && (

View File

@@ -18,7 +18,9 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { AIObservabilityPreference } from 'providers/preferences/types';
import { getTraceLink, transformSpanRows } from './utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
@@ -37,11 +39,7 @@ 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, TIMESTAMP_FIELD } from './configs';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
@@ -77,6 +75,24 @@ function ListView({
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { options, config } = useOptionsMenu({
dataSource: AIObservabilityPreference.LIST,
});
const selectedFields = useMemo(
() => options?.selectColumns ?? [],
[options?.selectColumns],
);
// Sorted names: stable on reorder, changes on add/remove (the API only returns selected columns).
const selectColumnsSignature = useMemo(
() =>
selectedFields
.map((field) => field.name)
.sort()
.join(','),
[selectedFields],
);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
@@ -97,12 +113,14 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
],
@@ -123,7 +141,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: defaultSelectedColumns,
selectColumns: selectedFields,
},
},
ENTITY_VERSION_V5,
@@ -131,7 +149,10 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!selectedFields.length,
},
);
@@ -156,13 +177,20 @@ 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),
),
[],
[
TIMESTAMP_FIELD,
...selectedFields.filter((field) => field.name !== TIMESTAMP_FIELD.name),
].map((field) => getFieldColumn(field)),
[selectedFields],
);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(cols.map((c) => c.id));
},
[config],
);
const rows = useMemo(
@@ -204,6 +232,7 @@ function ListView({
isLoading={isFetching}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={config}
/>
</div>
@@ -211,7 +240,6 @@ function ListView({
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
@@ -219,6 +247,8 @@ function ListView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);

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,12 @@ function TracesView({
<TracesTable
data={rows}
columns={tableColumns}
columns={columns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading || isColumnsLoading}
@@ -240,6 +232,8 @@ function TracesView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
/>
</div>
);

View File

@@ -1,5 +1,6 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
@@ -40,15 +41,51 @@ 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 while no selection is stored. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
export const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
export const getDefaultTraceViewFields = (
availableFields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
availableFields.filter((field) => DEFAULT_VISIBLE_FIELDS.has(field.name));
/** The selection the old column store encoded; `null` when it matches the defaults. */
export const getLegacyTraceViewSelection = (
availableFields: TelemetryFieldKey[],
hiddenColumnIds: string[],
columnOrder: string[],
): TelemetryFieldKey[] | null => {
const hidden = new Set(hiddenColumnIds);
// A saved order lists exactly the columns the user kept.
if (columnOrder.length) {
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
const kept = availableFields
.filter((field) => orderIndex.has(columnIdOf(field)))
.filter((field) => !hidden.has(columnIdOf(field)));
return kept.length
? kept.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? 0) -
(orderIndex.get(columnIdOf(b)) ?? 0),
)
: null;
}
const defaults = getDefaultTraceViewFields(availableFields);
const kept = defaults.filter((field) => !hidden.has(columnIdOf(field)));
return kept.length === defaults.length ? null : kept;
};
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,16 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } 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 { useOptionsMenu } from 'container/OptionsMenu';
import { AIObservabilityPreference } from 'providers/preferences/types';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
@@ -23,27 +22,41 @@ import {
} from '../constants';
import {
buildTraceViewColumns,
columnIdOf,
getDefaultTraceViewFields,
getLegacyTraceViewSelection,
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);
/** Now only column widths; it held the selection as hidden ids + order before the preference slice. */
const TABLE_STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
interface UseTraceViewColumns {
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.
const withTraceId = (
fields: TelemetryFieldKey[],
availableFields: TelemetryFieldKey[],
): TelemetryFieldKey[] => {
if (fields.some((field) => field.name === TRACE_ID_COLUMN_ID)) {
return fields;
}
const traceId = availableFields.find(
(field) => field.name === TRACE_ID_COLUMN_ID,
);
return traceId ? [traceId, ...fields] : fields;
};
export function useTraceViewColumns(): UseTraceViewColumns {
const {
data: fetchedFields = [],
@@ -66,60 +79,104 @@ export function useTraceViewColumns(): UseTraceViewColumns {
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
const { options, config } = useOptionsMenu({
dataSource: AIObservabilityPreference.TRACE,
});
// Nothing stored loads as an empty list; the defaults then come from the pool.
const storedFields = options?.selectColumns?.length
? options.selectColumns
: null;
const updateColumns = config?.fieldsSelector?.onFieldsChange;
// Captured on first render, before the table prunes hidden ids it no longer renders.
const legacyHiddenIds = useHiddenColumnIds(TABLE_STORAGE_KEY);
const legacyOrder = useColumnOrder(TABLE_STORAGE_KEY);
const legacy = useRef({ hiddenIds: legacyHiddenIds, order: legacyOrder });
const migratedFields = useMemo(
() =>
isSuccess && !storedFields
? getLegacyTraceViewSelection(
availableFields,
legacy.current.hiddenIds,
legacy.current.order,
)
: null,
[isSuccess, storedFields, availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
// updateColumns changes identity every render; the migration must still run once.
const hasMigrated = useRef(false);
useEffect(() => {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
if (!migratedFields || hasMigrated.current) {
return;
}
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
hasMigrated.current = true;
updateColumns?.(migratedFields);
// Clear the old encoding so a later reset to defaults isn't migrated again.
setColumnOrder(TABLE_STORAGE_KEY, []);
legacy.current.hiddenIds.forEach((id) => showColumn(TABLE_STORAGE_KEY, id));
legacy.current = { hiddenIds: [], order: [] };
}, [migratedFields, updateColumns]);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
const byName = new Map(availableFields.map((field) => [field.name, field]));
const source =
storedFields ?? migratedFields ?? getDefaultTraceViewFields(availableFields);
// Stored fields the endpoint no longer reports are dropped.
const resolved = source
.map((field) => byName.get(field.name))
.filter((field): field is TelemetryFieldKey => !!field);
return withTraceId(resolved, availableFields);
}, [availableFields, storedFields, migratedFields]);
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
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));
updateColumns?.(withTraceId(next, availableFields));
},
[columns, isSuccess],
[isSuccess, availableFields, updateColumns],
);
const onColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
const orderIndex = new Map(cols.map((col, index) => [col.id, index]));
onFieldsChange(
[...selectedFields].sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
),
);
},
[selectedFields, onFieldsChange],
);
const onColumnRemove = useCallback(
(columnId: string): void => {
if (columnId === TRACE_ID_COLUMN_ID) {
return;
}
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

@@ -10,7 +10,13 @@ import { useNotifications } from 'hooks/useNotifications';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { has } from 'lodash-es';
import { AllTraceFilterKeyValue } from 'constants/traceFilterKeys';
import {
AI_OBSERVABILITY_URL_PARAMS,
getAIObservabilityDefaultColumns,
isAIObservabilityPreference,
} from 'providers/preferences/configs/aiObservabilityConfig';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { PreferenceDataSource } from 'providers/preferences/types';
import {
QueryKeyRequestProps,
QueryKeySuggestionsResponseProps,
@@ -40,7 +46,8 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
/** Whose preferences; AI explorer values still search fields as traces. */
dataSource: PreferenceDataSource;
aggregateOperator?: string;
initialOptions?: InitialOptions;
}
@@ -52,14 +59,20 @@ interface UseOptionsMenu {
}
const useOptionsMenu = ({
dataSource,
dataSource: preferenceDataSource,
// aggregateOperator,
initialOptions = {},
}: UseOptionsMenuProps): UseOptionsMenu => {
const isAIObservability = isAIObservabilityPreference(preferenceDataSource);
const dataSource = isAIObservability ? DataSource.TRACES : preferenceDataSource;
const { notifications } = useNotifications();
const prefCtx = usePreferenceContext();
// TODO: send null to updateColumns and updateFormatting if dataSource is not logs or traces
const slice = dataSource === DataSource.TRACES ? prefCtx.traces : prefCtx.logs;
let slice = dataSource === DataSource.TRACES ? prefCtx.traces : prefCtx.logs;
if (isAIObservability) {
slice = prefCtx[preferenceDataSource];
}
const { preferences, updateColumns, updateFormatting } = slice;
const [searchText, setSearchText] = useState<string>('');
@@ -75,7 +88,12 @@ const useOptionsMenu = ({
);
const { query: optionsQuery, redirectWithQuery: redirectWithOptionsData } =
useUrlQueryData<OptionsQuery>(URL_OPTIONS, defaultOptionsQuery);
useUrlQueryData<OptionsQuery>(
isAIObservability
? AI_OBSERVABILITY_URL_PARAMS[preferenceDataSource]
: URL_OPTIONS,
defaultOptionsQuery,
);
const initialQueriesV5 = useMemo(
() =>
@@ -223,6 +241,10 @@ const useOptionsMenu = ({
}));
}
if (isAIObservability) {
defaultColumns = getAIObservabilityDefaultColumns(preferenceDataSource);
}
const finalSelectColumns = initialOptions?.selectColumns
? initialSelectedColumns
: defaultColumns;
@@ -232,7 +254,13 @@ const useOptionsMenu = ({
...initialOptions,
selectColumns: finalSelectColumns,
};
}, [dataSource, initialOptions, initialSelectedColumns]);
}, [
dataSource,
isAIObservability,
preferenceDataSource,
initialOptions,
initialSelectedColumns,
]);
const selectedColumnKeys = useMemo(
() => preferences?.columns?.map(({ name }) => name) || [],

View File

@@ -8,6 +8,8 @@ import {
buildSavedViewDeletePermission,
buildSavedViewUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { panelTypeToAIObservabilityPreference } from 'providers/preferences/configs/aiObservabilityConfig';
import { PreferenceDataSource } from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
@@ -22,6 +24,17 @@ export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
/** Whose column preferences a view saves; AI views use the tab they were saved from. */
export function getSavedViewPreferenceDataSource(
source: SavedviewtypesSourceDTO,
panelType: string | undefined,
): PreferenceDataSource | undefined {
if (source === SavedviewtypesSourceDTO.ai_observability) {
return panelTypeToAIObservabilityPreference(panelType);
}
return SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
}
// What selecting a view writes to the url, so clearing it removes exactly these.
export const SAVED_VIEW_URL_PARAMS = [
QueryParams.viewKey,

View File

@@ -11,7 +11,7 @@ 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 { getSavedViewPreferenceDataSource } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
@@ -21,7 +21,10 @@ export function useActiveSavedView(
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const optionsDataSource = getSavedViewPreferenceDataSource(
source,
panelType ?? undefined,
);
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
@@ -64,6 +67,7 @@ export function useActiveSavedView(
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
optionsDataSource,
});
}, [
view,

View File

@@ -20,13 +20,14 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isAIObservabilityPreference } from 'providers/preferences/configs/aiObservabilityConfig';
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,
getSavedViewPreferenceDataSource,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
@@ -60,15 +61,14 @@ export function useSavedViewActions(
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const optionsDataSource = getSavedViewPreferenceDataSource(
source,
panelType ?? undefined,
);
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const preferences = usePreferenceContext();
const preferencesSlice =
optionsDataSource === DataSource.TRACES
? preferences.traces
: preferences.logs;
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
@@ -105,10 +105,22 @@ export function useSavedViewActions(
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (optionsDataSource) {
// The view's own tab, which can differ from the open one.
const viewDataSource = getSavedViewPreferenceDataSource(
source,
view.spec.panelType,
);
if (viewDataSource) {
let preferencesSlice =
viewDataSource === DataSource.TRACES
? preferences.traces
: preferences.logs;
if (isAIObservabilityPreference(viewDataSource)) {
preferencesSlice = preferences[viewDataSource];
}
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
optionsDataSource,
viewDataSource,
);
preferencesSlice.updateColumns(columns);
if (formatting) {
@@ -120,7 +132,7 @@ export function useSavedViewActions(
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
[source, preferences, selectView, logSavedViewEvent],
);
// The query builder redirects only ever add params, so the url is rebuilt

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 { PreferenceDataSource } from 'providers/preferences/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -32,6 +33,8 @@ export interface HasUnsavedViewChangesArgs {
stagedQuery: Query | null;
panelType: PANEL_TYPES;
options?: SavedViewOptions;
/** Whose defaults the options are compared against; the query's data source when unset. */
optionsDataSource?: PreferenceDataSource;
}
export interface UseLastUsedViewResult {

View File

@@ -6,7 +6,15 @@ import {
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Preferences } from 'providers/preferences/types';
import {
getAIObservabilityDefaultColumns,
isAIObservabilityPreference,
sanitizeColumns,
} from 'providers/preferences/configs/aiObservabilityConfig';
import {
PreferenceDataSource,
Preferences,
} from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
function withColumnNames(
@@ -25,8 +33,16 @@ function withColumnNames(
// defaults filled in where the view has none.
export function getViewColumnsAndFormatting(
spec: SavedviewtypesSavedViewSpecDTO | undefined,
dataSource: DataSource,
dataSource: PreferenceDataSource,
): Preferences {
if (isAIObservabilityPreference(dataSource)) {
return {
columns:
sanitizeColumns(dataSource, spec?.selectedFields) ??
getAIObservabilityDefaultColumns(dataSource),
};
}
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
if (dataSource === DataSource.LOGS) {

View File

@@ -50,6 +50,7 @@ export function hasUnsavedViewChanges({
stagedQuery,
panelType,
options,
optionsDataSource,
}: HasUnsavedViewChangesArgs): boolean {
if (!stagedQuery) {
return false;
@@ -79,7 +80,7 @@ export function hasUnsavedViewChanges({
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,
optionsDataSource ?? dataSource,
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);

View File

@@ -0,0 +1,136 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultSelectedColumns } from 'container/LLMObservability/Explorer/ListView/configs';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { AIObservabilityPreference, PreferenceDataSource } from '../types';
// TODO(ai-explorer): share `options`, keyed by `panelTypes`, once Logs/Traces no longer read or write it on this page.
export const AI_OBSERVABILITY_URL_PARAMS: Record<
AIObservabilityPreference,
string
> = {
[AIObservabilityPreference.LIST]: 'listOptions',
[AIObservabilityPreference.TRACE]: 'traceOptions',
};
export const AI_OBSERVABILITY_STORAGE_KEYS: Record<
AIObservabilityPreference,
LOCALSTORAGE
> = {
[AIObservabilityPreference.LIST]: LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS,
[AIObservabilityPreference.TRACE]: LOCALSTORAGE.AI_OBSERVABILITY_TRACE_OPTIONS,
};
// Trace view defaults depend on the aggregates the keys endpoint reports, so the view applies them.
const AI_OBSERVABILITY_DEFAULT_COLUMNS: Record<
AIObservabilityPreference,
TelemetryFieldKey[]
> = {
[AIObservabilityPreference.LIST]: defaultSelectedColumns,
[AIObservabilityPreference.TRACE]: [],
};
export const isAIObservabilityPreference = (
dataSource: PreferenceDataSource,
): dataSource is AIObservabilityPreference =>
Object.values(AIObservabilityPreference).includes(
dataSource as AIObservabilityPreference,
);
export const getAIObservabilityDefaultColumns = (
preference: AIObservabilityPreference,
): TelemetryFieldKey[] => AI_OBSERVABILITY_DEFAULT_COLUMNS[preference];
type RawColumn = Partial<TelemetryFieldKey> & { key?: string };
/** Drops malformed, non-trace and duplicate fields; `null` when nothing usable is left. */
export const sanitizeColumns = (
preference: AIObservabilityPreference,
columns: unknown,
): TelemetryFieldKey[] | null => {
if (!Array.isArray(columns)) {
return null;
}
const seen = new Set<string>();
const sanitized = (columns as RawColumn[]).reduce<TelemetryFieldKey[]>(
(acc, column) => {
const name = column?.name ?? column?.key;
if (typeof name !== 'string' || !name) {
return acc;
}
if (column.signal && column.signal !== 'traces') {
return acc;
}
// Per-trace aggregates have no value on a span row.
if (
preference === AIObservabilityPreference.LIST &&
column.fieldContext === 'trace'
) {
return acc;
}
const id = buildCompositeKey(
name,
column.fieldContext,
column.fieldDataType,
);
if (seen.has(id)) {
return acc;
}
seen.add(id);
const { key: _key, ...field } = column;
acc.push({ ...field, name } as TelemetryFieldKey);
return acc;
},
[],
);
return sanitized.length ? sanitized : null;
};
const PANEL_TYPE_TO_PREFERENCE: Record<string, AIObservabilityPreference> = {
list: AIObservabilityPreference.LIST,
trace: AIObservabilityPreference.TRACE,
};
/** The AI tab whose columns a panel type shows; `undefined` for tabs without columns. */
export const panelTypeToAIObservabilityPreference = (
panelType: string | undefined,
): AIObservabilityPreference | undefined =>
panelType ? PANEL_TYPE_TO_PREFERENCE[panelType] : undefined;
/** A saved view's columns for this tab, defaults filled in; `null` when the view belongs to another tab. */
export const getAIObservabilityViewColumns = (
preference: AIObservabilityPreference,
view: SavedviewtypesSavedViewDTO,
): TelemetryFieldKey[] | null => {
if (
view.source !== SavedviewtypesSourceDTO.ai_observability ||
panelTypeToAIObservabilityPreference(view.spec.panelType) !== preference
) {
return null;
}
return (
sanitizeColumns(preference, view.spec.selectedFields) ??
AI_OBSERVABILITY_DEFAULT_COLUMNS[preference]
);
};
/** `selectColumns` of a stored options object; `null` when absent or unparseable. */
export const parseSelectColumns = (raw: string | null): unknown => {
if (!raw) {
return null;
}
try {
return JSON.parse(raw)?.selectColumns;
} catch {
return null;
}
};

View File

@@ -0,0 +1,46 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { TelemetryFieldKey } from 'api/v5/v5';
import { AIObservabilityPreference } from '../types';
import {
AI_OBSERVABILITY_STORAGE_KEYS,
AI_OBSERVABILITY_URL_PARAMS,
getAIObservabilityDefaultColumns,
parseSelectColumns,
sanitizeColumns,
} from './aiObservabilityConfig';
// --- AI OBSERVABILITY preferences loader config, one per explorer tab ---
const getAIObservabilityLoaderConfig = (
preference: AIObservabilityPreference,
): {
local: () => { columns: TelemetryFieldKey[] };
url: () => { columns: TelemetryFieldKey[] };
default: () => { columns: TelemetryFieldKey[] };
priority: readonly ['local', 'url', 'default'];
} => ({
local: () => ({
columns:
sanitizeColumns(
preference,
parseSelectColumns(
getLocalStorageKey(AI_OBSERVABILITY_STORAGE_KEYS[preference]),
),
) ?? [],
}),
url: () => ({
columns:
sanitizeColumns(
preference,
parseSelectColumns(
new URLSearchParams(window.location.search).get(
AI_OBSERVABILITY_URL_PARAMS[preference],
),
),
) ?? [],
}),
default: () => ({ columns: getAIObservabilityDefaultColumns(preference) }),
priority: ['local', 'url', 'default'] as const,
});
export default getAIObservabilityLoaderConfig;

View File

@@ -0,0 +1,45 @@
import { Dispatch, SetStateAction } from 'react';
import removeLocalStorageKey from 'api/browser/localstorage/remove';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { TelemetryFieldKey } from 'api/v5/v5';
import { defaultOptionsQuery } from 'container/OptionsMenu/constants';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { AIObservabilityPreference, PreferenceMode, Preferences } from '../types';
import {
AI_OBSERVABILITY_STORAGE_KEYS,
sanitizeColumns,
} from './aiObservabilityConfig';
// --- AI OBSERVABILITY preferences updater config, one per explorer tab ---
const getAIObservabilityUpdaterConfig = (
preference: AIObservabilityPreference,
redirectWithOptionsData: (options: OptionsQuery) => void,
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
): {
updateColumns: (newColumns: TelemetryFieldKey[], mode: string) => void;
updateFormatting: () => void;
} => ({
updateColumns: (newColumns: TelemetryFieldKey[], mode: string): void => {
// An emptied selection is stored as none, so the loader falls back to the defaults.
const columns = sanitizeColumns(preference, newColumns) ?? [];
if (mode === PreferenceMode.SAVED_VIEW) {
setSavedViewPreferences({ columns });
return;
}
redirectWithOptionsData({ ...defaultOptionsQuery, selectColumns: columns });
if (columns.length) {
setLocalStorageKey(
AI_OBSERVABILITY_STORAGE_KEYS[preference],
JSON.stringify({ selectColumns: columns }),
);
} else {
removeLocalStorageKey(AI_OBSERVABILITY_STORAGE_KEYS[preference]);
}
},
updateFormatting: (): void => {}, // no-op for the AI explorer
});
export default getAIObservabilityUpdaterConfig;

View File

@@ -7,6 +7,7 @@ import React, {
} from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import {
AIObservabilityPreference,
PreferenceContextValue,
PreferenceMode,
} from 'providers/preferences/types';
@@ -47,12 +48,32 @@ export function PreferenceContextProvider({
dataSource: DataSource.TRACES,
});
const aiObservabilityListSlice = usePreferenceSync({
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
savedViewId: savedViewId || undefined,
dataSource: AIObservabilityPreference.LIST,
});
const aiObservabilityTraceSlice = usePreferenceSync({
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
savedViewId: savedViewId || undefined,
dataSource: AIObservabilityPreference.TRACE,
});
const value = useMemo<PreferenceContextValue>(
() => ({
logs: logsSlice,
traces: tracesSlice,
[AIObservabilityPreference.LIST]: aiObservabilityListSlice,
[AIObservabilityPreference.TRACE]: aiObservabilityTraceSlice,
}),
[logsSlice, tracesSlice],
[
logsSlice,
tracesSlice,
aiObservabilityListSlice,
aiObservabilityTraceSlice,
],
);
return (

View File

@@ -6,7 +6,13 @@ import { DataSource } from 'types/common/queryBuilder';
import logsLoaderConfig from '../configs/logsLoaderConfig';
import tracesLoaderConfig from '../configs/tracesLoaderConfig';
import { FormattingOptions, Preferences } from '../types';
import { isAIObservabilityPreference } from '../configs/aiObservabilityConfig';
import getAIObservabilityLoaderConfig from '../configs/aiObservabilityLoaderConfig';
import {
FormattingOptions,
PreferenceDataSource,
Preferences,
} from '../types';
const migrateColumns = (columns: any): any =>
columns.map((column: any) => {
@@ -65,12 +71,30 @@ function tracesPreferencesLoader(): {
return preferencesLoader(tracesLoaderConfig);
}
function readPreferences(dataSource: PreferenceDataSource): Preferences | null {
if (dataSource === DataSource.LOGS) {
const { columns, formatting } = logsPreferencesLoader();
return { columns, formatting };
}
if (dataSource === DataSource.TRACES) {
const { columns } = tracesPreferencesLoader();
return { columns };
}
if (isAIObservabilityPreference(dataSource)) {
const { columns } = preferencesLoader<{ columns: TelemetryFieldKey[] }>(
getAIObservabilityLoaderConfig(dataSource),
);
return { columns };
}
return null;
}
export function usePreferenceLoader({
dataSource,
reSync,
setReSync,
}: {
dataSource: DataSource;
dataSource: PreferenceDataSource;
reSync: boolean;
setReSync: (value: boolean) => void;
}): {
@@ -78,17 +102,9 @@ export function usePreferenceLoader({
loading: boolean;
error: Error | null;
} {
const [preferences, setPreferences] = useState<Preferences | null>(() => {
if (dataSource === DataSource.LOGS) {
const { columns, formatting } = logsPreferencesLoader();
return { columns, formatting };
}
if (dataSource === DataSource.TRACES) {
const { columns } = tracesPreferencesLoader();
return { columns };
}
return null;
});
const [preferences, setPreferences] = useState<Preferences | null>(() =>
readPreferences(dataSource),
);
const [error, setError] = useState<Error | null>(null);
useEffect((): void => {
@@ -96,15 +112,7 @@ export function usePreferenceLoader({
setError(null);
try {
if (dataSource === DataSource.LOGS) {
const { columns, formatting } = logsPreferencesLoader();
setPreferences({ columns, formatting });
}
if (dataSource === DataSource.TRACES) {
const { columns } = tracesPreferencesLoader();
setPreferences({ columns });
}
setPreferences(readPreferences(dataSource));
} catch (e) {
setError(e as Error);
} finally {

View File

@@ -3,10 +3,18 @@ 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 {
getAIObservabilityViewColumns,
isAIObservabilityPreference,
} from '../configs/aiObservabilityConfig';
import {
FormattingOptions,
PreferenceDataSource,
PreferenceMode,
Preferences,
} from '../types';
import { usePreferenceUpdater } from '../updater/usePreferenceUpdater';
export function usePreferenceSync({
@@ -15,7 +23,7 @@ export function usePreferenceSync({
savedViewId,
}: {
mode: PreferenceMode;
dataSource: DataSource;
dataSource: PreferenceDataSource;
savedViewId: string | undefined;
}): {
preferences: Preferences | null;
@@ -37,6 +45,10 @@ export function usePreferenceSync({
// memory. Discard writes the saved ones back through the updater.
const seededViewId = useRef<string>();
useEffect(() => {
// AI explorer tabs are seeded below, once their own preferences have loaded.
if (isAIObservabilityPreference(dataSource)) {
return;
}
const view = viewData?.data;
if (view && view.id === savedViewId) {
if (seededViewId.current !== savedViewId) {
@@ -58,6 +70,28 @@ export function usePreferenceSync({
setReSync,
});
useEffect(() => {
if (!isAIObservabilityPreference(dataSource)) {
return;
}
const view = viewData?.data;
if (view && view.id === savedViewId) {
if (seededViewId.current !== savedViewId) {
seededViewId.current = savedViewId;
// Only the tab the view was saved from takes its columns; the other keeps the user's own.
setSavedViewPreferences({
columns:
getAIObservabilityViewColumns(dataSource, view) ??
preferences?.columns ??
[],
});
}
return;
}
seededViewId.current = undefined;
setSavedViewPreferences(null);
}, [viewData, dataSource, savedViewId, preferences]);
const { updateColumns, updateFormatting } = usePreferenceUpdater({
dataSource,
mode,

View File

@@ -1,12 +1,21 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize } from 'container/OptionsMenu/types';
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
import { DataSource } from 'types/common/queryBuilder';
export enum PreferenceMode {
SAVED_VIEW = 'savedView',
DIRECT = 'direct',
}
/** AI Explorer tabs; their queries still run on the traces data source. */
export enum AIObservabilityPreference {
LIST = 'ai_observability_list',
TRACE = 'ai_observability_trace',
}
/** Whose preferences: a data source, or an explorer sharing one with another. */
export type PreferenceDataSource = DataSource | AIObservabilityPreference;
export interface PreferenceSlice {
preferences: Preferences | null;
loading: boolean;
@@ -18,6 +27,8 @@ export interface PreferenceSlice {
export interface PreferenceContextValue {
logs: PreferenceSlice;
traces: PreferenceSlice;
[AIObservabilityPreference.LIST]: PreferenceSlice;
[AIObservabilityPreference.TRACE]: PreferenceSlice;
}
export interface FormattingOptions {

View File

@@ -10,7 +10,16 @@ import { DataSource } from 'types/common/queryBuilder';
import getLogsUpdaterConfig from '../configs/logsUpdaterConfig';
import getTracesUpdaterConfig from '../configs/tracesUpdaterConfig';
import { FormattingOptions, Preferences } from '../types';
import {
AI_OBSERVABILITY_URL_PARAMS,
isAIObservabilityPreference,
} from '../configs/aiObservabilityConfig';
import getAIObservabilityUpdaterConfig from '../configs/aiObservabilityUpdaterConfig';
import {
FormattingOptions,
PreferenceDataSource,
Preferences,
} from '../types';
const metricsUpdater = {
updateColumns: (): void => {}, // no-op for metrics
@@ -47,7 +56,7 @@ export function usePreferenceUpdater({
setReSync,
setSavedViewPreferences,
}: {
dataSource: DataSource;
dataSource: PreferenceDataSource;
mode: string;
preferences: Preferences | null;
setReSync: Dispatch<SetStateAction<boolean>>;
@@ -57,12 +66,23 @@ export function usePreferenceUpdater({
updateFormatting: (newFormatting: FormattingOptions) => void;
} {
const { redirectWithQuery: redirectWithOptionsData } =
useUrlQueryData<OptionsQuery>(URL_OPTIONS, defaultOptionsQuery);
const updater = getUpdaterConfig(
preferences,
redirectWithOptionsData,
setSavedViewPreferences,
)[dataSource];
useUrlQueryData<OptionsQuery>(
isAIObservabilityPreference(dataSource)
? AI_OBSERVABILITY_URL_PARAMS[dataSource]
: URL_OPTIONS,
defaultOptionsQuery,
);
const updater = isAIObservabilityPreference(dataSource)
? getAIObservabilityUpdaterConfig(
dataSource,
redirectWithOptionsData,
setSavedViewPreferences,
)
: getUpdaterConfig(
preferences,
redirectWithOptionsData,
setSavedViewPreferences,
)[dataSource];
return {
updateColumns: (newColumns: TelemetryFieldKey[]): void => {