Compare commits

...

2 Commits

Author SHA1 Message Date
Pandey
8286e787b2 fix(tracefunnel): quote step names in slow and error trace queries (#12886)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- `#12593` moved the n-step trace-funnel query builders onto
`clickhousesql.StringLiteral`, but the two-step `slow-traces` and
`error-traces` builders still interpolated `service_name`/`span_name`
into the SQL string literal raw.
- Route those four values through the same helper, so every funnel query
builder quotes step names consistently.

#### Additional Information

- No behaviour change for ordinary names; the
`slow-traces`/`error-traces` funnel queries now handle names containing
a quote the same way the rest of the module already does.
2026-09-17 07:01:21 +00:00
Gaurav Tewari
e504c4081e feat/add support for fetch AI fieldKeys in order by and options (#12859)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adds **Order by** and **Options** (column picker) to the AI
observability trace view.
- Both get their field list from one shared hook,
`useFieldKeysSuggestion`. The hook only fetches the keys. It does not
add or change anything.
- `FieldsSelector` and `ListViewOrderBy` now take one prop,
`useFieldApis`. It holds the API params and the static fields.
- Static fields are the ones the API never sends back, like `timestamp`
and `last_activity_time`. The component adds them to the list on its
own.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

Stacked on #12846. Please review and merge that one first.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-17 05:10:20 +00:00
25 changed files with 1235 additions and 166 deletions

View File

@@ -37,12 +37,13 @@ describe('getFieldKeySuggestions', () => {
const response = keysResponse();
mockedAIKeys.mockResolvedValue(response);
const filterConfig = { searchText: 'llm' };
const fieldKeysConfig = { searchText: 'llm' };
const abortSignal = new AbortController().signal;
await expect(
getFieldKeySuggestions(filterConfig, 'builder_ai_query'),
getFieldKeySuggestions(fieldKeysConfig, 'builder_ai_query', abortSignal),
).resolves.toBe(response);
expect(mockedAIKeys).toHaveBeenCalledWith(filterConfig);
expect(mockedAIKeys).toHaveBeenCalledWith(fieldKeysConfig, abortSignal);
expect(mockedGenericKeys).not.toHaveBeenCalled();
});
@@ -58,15 +59,16 @@ describe('getFieldKeySuggestions', () => {
const response = keysResponse();
mockedGenericKeys.mockResolvedValue(response);
const filterConfig = {
const fieldKeysConfig = {
signal: TelemetrytypesSignalDTO.traces,
searchText: 'svc',
};
const abortSignal = new AbortController().signal;
await expect(
getFieldKeySuggestions(filterConfig, builderQueryType),
getFieldKeySuggestions(fieldKeysConfig, builderQueryType, abortSignal),
).resolves.toBe(response);
expect(mockedGenericKeys).toHaveBeenCalledWith(filterConfig);
expect(mockedGenericKeys).toHaveBeenCalledWith(fieldKeysConfig, abortSignal);
expect(mockedAIKeys).not.toHaveBeenCalled();
});
});

View File

@@ -34,12 +34,13 @@ describe('getFieldValueSuggestions', () => {
const response = valuesResponse();
mockedAIValues.mockResolvedValue(response);
const filterConfig = { name: 'gen_ai.request.model', searchText: 'gpt' };
const fieldValuesConfig = { name: 'gen_ai.request.model', searchText: 'gpt' };
const abortSignal = new AbortController().signal;
await expect(
getFieldValueSuggestions(filterConfig, 'builder_ai_query'),
getFieldValueSuggestions(fieldValuesConfig, 'builder_ai_query', abortSignal),
).resolves.toBe(response);
expect(mockedAIValues).toHaveBeenCalledWith(filterConfig);
expect(mockedAIValues).toHaveBeenCalledWith(fieldValuesConfig, abortSignal);
expect(mockedGenericValues).not.toHaveBeenCalled();
});
@@ -55,16 +56,20 @@ describe('getFieldValueSuggestions', () => {
const response = valuesResponse();
mockedGenericValues.mockResolvedValue(response);
const filterConfig = {
const fieldValuesConfig = {
signal: TelemetrytypesSignalDTO.traces,
name: 'service.name',
searchText: 'front',
};
const abortSignal = new AbortController().signal;
await expect(
getFieldValueSuggestions(filterConfig, builderQueryType),
getFieldValueSuggestions(fieldValuesConfig, builderQueryType, abortSignal),
).resolves.toBe(response);
expect(mockedGenericValues).toHaveBeenCalledWith(filterConfig);
expect(mockedGenericValues).toHaveBeenCalledWith(
fieldValuesConfig,
abortSignal,
);
expect(mockedAIValues).not.toHaveBeenCalled();
});
});

View File

@@ -2,12 +2,13 @@ import { getAIObservabilityFieldsKeys } from 'api/generated/services/ai-observab
import { getFieldsKeys } from 'api/generated/services/fields';
import type { BuilderQueryType } from 'types/api/v5/queryRange';
import { FieldKeysFilterConfig, FieldKeysResponse } from './types';
import { FieldKeysConfig, FieldKeysResponse } from './types';
export const getFieldKeySuggestions = (
filterConfig: FieldKeysFilterConfig,
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
abortSignal?: AbortSignal,
): Promise<FieldKeysResponse> =>
builderQueryType === 'builder_ai_query'
? getAIObservabilityFieldsKeys(filterConfig)
: getFieldsKeys(filterConfig);
? getAIObservabilityFieldsKeys(fieldKeysConfig, abortSignal)
: getFieldsKeys(fieldKeysConfig, abortSignal);

View File

@@ -2,12 +2,13 @@ import { getAIObservabilityFieldsValues } from 'api/generated/services/ai-observ
import { getFieldsValues } from 'api/generated/services/fields';
import type { BuilderQueryType } from 'types/api/v5/queryRange';
import { FieldValuesFilterConfig, FieldValuesResponse } from './types';
import { FieldValuesConfig, FieldValuesResponse } from './types';
export const getFieldValueSuggestions = (
filterConfig: FieldValuesFilterConfig,
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
abortSignal?: AbortSignal,
): Promise<FieldValuesResponse> =>
builderQueryType === 'builder_ai_query'
? getAIObservabilityFieldsValues(filterConfig)
: getFieldsValues(filterConfig);
? getAIObservabilityFieldsValues(fieldValuesConfig, abortSignal)
: getFieldsValues(fieldValuesConfig, abortSignal);

View File

@@ -1,7 +1,7 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
@@ -9,14 +9,19 @@ import type {
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysFilterConfig =
export type FieldKeysConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldValuesFilterConfig =
export type FieldValuesConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldKeysConfigProp = Omit<
FieldKeysConfig,
'signal' | 'searchText'
>;
export type FieldKeysResponse =
| GetFieldsKeys200
| GetAIObservabilityFieldsKeys200;

View File

@@ -6,7 +6,8 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
@@ -31,6 +32,9 @@ interface FieldsSelectorProps {
// Lets users add a free-typed field which
// does not show up in the suggestions
allowCustomFields?: boolean;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
width?: number;
height?: number;
defaultPosition?: { x: number; y: number };
@@ -50,6 +54,9 @@ function FieldsSelectorContent({
maxFields,
requiredFields,
allowCustomFields,
fieldKeysConfig,
builderQueryType,
extraFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
@@ -158,6 +165,9 @@ function FieldsSelectorContent({
onAdd={handleAdd}
isAtLimit={isAtLimit}
allowCustomFields={allowCustomFields}
fieldKeysConfig={fieldKeysConfig}
builderQueryType={builderQueryType}
extraFields={extraFields}
/>
{hasUnsavedChanges && (

View File

@@ -3,18 +3,22 @@ import { Button } from '@signozhq/ui/button';
import { Skeleton } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
BuilderQueryType,
FieldContext,
SignalType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import { mergeExtraFields } from 'utils/extraFields';
import styles from './FieldsSelector.module.scss';
const EMPTY_EXTRA_FIELDS: TelemetryFieldKey[] = [];
interface OtherFieldsProps {
signal: DataSource;
debouncedInputValue: string;
@@ -22,6 +26,9 @@ interface OtherFieldsProps {
onAdd: (field: TelemetryFieldKey) => void;
isAtLimit: boolean;
allowCustomFields?: boolean;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
}
function OtherFields({
@@ -31,26 +38,26 @@ function OtherFields({
onAdd,
isAtLimit,
allowCustomFields,
fieldKeysConfig,
builderQueryType,
extraFields = EMPTY_EXTRA_FIELDS,
}: OtherFieldsProps): JSX.Element {
const { data, isFetching } = useGetQueryKeySuggestions(
const { data: fetchedFields, isFetching } = useFieldKeysSuggestion(
{
signal,
...fieldKeysConfig,
signal: DATA_SOURCE_TO_SIGNAL[signal],
searchText: debouncedInputValue,
},
{
queryKey: [
REACT_QUERY_KEY.GET_FIELDS_SELECTOR_SUGGESTIONS,
signal,
debouncedInputValue,
],
enabled: true,
},
builderQueryType,
);
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
const search = debouncedInputValue.trim().toLowerCase();
// Normalize: synthesize `key` once so downstream reads can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
const suggestions: TelemetryFieldKey[] = mergeExtraFields(
extraFields.filter((field) => field.name.toLowerCase().includes(search)),
fetchedFields ?? [],
).map((attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
signal: attr.signal as SignalType,
@@ -87,7 +94,13 @@ function OtherFields({
key: buildCompositeKey(typed, ''),
};
return [customField, ...available];
}, [data, addedFields, allowCustomFields, debouncedInputValue]);
}, [
extraFields,
fetchedFields,
addedFields,
allowCustomFields,
debouncedInputValue,
]);
if (isFetching) {
return (

View File

@@ -1,11 +1,17 @@
import { act, fireEvent, render, screen } from 'tests/test-utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSelector from '../FieldsSelector';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
useFieldKeysSuggestion: jest.fn(() => ({
data: undefined,
isFetching: false,
isFetched: true,
})),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
@@ -21,22 +27,15 @@ jest.mock('periscope/components/FloatingPanel', () => ({
}));
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
data: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
isFetching: false,
isFetched: true,
});
};

View File

@@ -1,29 +1,30 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import OtherFields from '../OtherFields';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
useFieldKeysSuggestion: jest.fn(() => ({
data: undefined,
isFetching: false,
isFetched: true,
})),
}));
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
data: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
isFetching: false,
isFetched: true,
});
};
@@ -82,7 +83,6 @@ describe('OtherFields — custom (free-typed) option', () => {
mockSuggestions(['orderId']);
renderOtherFields({ debouncedInputValue: 'orderid' });
// the real suggestion shows, the lowercased custom name does not
expect(screen.getByText('orderId')).toBeInTheDocument();
expect(screen.queryByText('orderid')).not.toBeInTheDocument();
});
@@ -116,10 +116,126 @@ describe('OtherFields — custom (free-typed) option', () => {
it('shows the custom option at the field limit but hides its Add button', () => {
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c', isAtLimit: true });
// same as every other row at the limit: name shown, no Add button
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /add/i }),
).not.toBeInTheDocument();
});
});
describe('OtherFields — field keys config', () => {
const pool: TelemetryFieldKey[] = [
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
{ name: 'llm_call_count', fieldContext: 'trace', fieldDataType: 'float64' },
];
const fieldKeysConfig: FieldKeysConfigProp = {
fieldContext: TelemetrytypesFieldContextDTO.trace,
};
const builderQueryType: BuilderQueryType = 'builder_ai_query';
const mockPool = (fields: TelemetryFieldKey[]): void => {
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
data: fields,
isFetching: false,
isFetched: true,
});
};
beforeEach(() => {
mockPool(pool);
});
it('lists the pool it is handed', () => {
renderOtherFields({
fieldKeysConfig,
builderQueryType,
allowCustomFields: false,
});
expect(screen.getByText('total_tokens')).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('forwards the fetch params and search to the shared keys hook', () => {
renderOtherFields({
fieldKeysConfig,
builderQueryType,
allowCustomFields: false,
debouncedInputValue: 'llm',
});
expect(useFieldKeysSuggestion).toHaveBeenCalledWith(
{
...fieldKeysConfig,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.LOGS],
searchText: 'llm',
},
builderQueryType,
);
});
it('lists extra fields the keys endpoint never returns', () => {
mockPool([{ name: 'total_tokens' } as TelemetryFieldKey]);
renderOtherFields({
fieldKeysConfig,
builderQueryType,
extraFields: [{ name: 'last_activity_time' } as TelemetryFieldKey],
allowCustomFields: false,
});
expect(screen.getByText('last_activity_time')).toBeInTheDocument();
expect(screen.getByText('total_tokens')).toBeInTheDocument();
});
it('filters extra fields by search text', () => {
mockPool([]);
renderOtherFields({
fieldKeysConfig,
builderQueryType,
extraFields: [
{ name: 'last_activity_time' } as TelemetryFieldKey,
{ name: 'timestamp' } as TelemetryFieldKey,
],
debouncedInputValue: 'activity',
allowCustomFields: false,
});
expect(screen.getByText('last_activity_time')).toBeInTheDocument();
expect(screen.queryByText('timestamp')).not.toBeInTheDocument();
});
it('keeps a fetched key whose name does not contain the search text', () => {
mockPool([
{ name: 'service.name', fieldContext: 'resource' } as TelemetryFieldKey,
]);
renderOtherFields({
debouncedInputValue: 'resource.service',
allowCustomFields: false,
});
expect(screen.getByText('service.name')).toBeInTheDocument();
});
it('omits pool fields that are already added', () => {
renderOtherFields({
fieldKeysConfig,
builderQueryType,
allowCustomFields: false,
addedFields: [
{
name: 'total_tokens',
fieldContext: 'trace',
fieldDataType: 'float64',
key: 'trace:total_tokens:float64',
},
],
});
expect(screen.queryByText('total_tokens')).not.toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
});

View File

@@ -1,16 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { Select, Spin } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import './ListViewOrderBy.styles.scss';
const DEFAULT_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'timestamp' } as TelemetryFieldKey,
];
interface ListViewOrderByProps {
value: string;
onChange: (value: string) => void;
dataSource: DataSource;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
}
// Loader component for the dropdown when loading or no results
@@ -26,6 +33,9 @@ function ListViewOrderBy({
value,
onChange,
dataSource,
fieldKeysConfig,
builderQueryType,
extraFields = DEFAULT_EXTRA_FIELDS,
}: ListViewOrderByProps): JSX.Element {
const [searchInput, setSearchInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
@@ -34,17 +44,14 @@ function ListViewOrderBy({
>([]);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fetch key suggestions based on debounced input
const { data, isLoading } = useQuery({
queryKey: ['orderByKeySuggestions', dataSource, debouncedInput],
queryFn: async () => {
const response = await getKeySuggestions({
signal: dataSource,
searchText: debouncedInput,
});
return response.data;
const { data, isLoading } = useFieldKeysSuggestion(
{
...fieldKeysConfig,
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
searchText: debouncedInput,
},
});
builderQueryType,
);
useEffect(
() => (): void => {
@@ -55,24 +62,24 @@ function ListViewOrderBy({
[],
);
const extraKeysSignature = extraFields.map((field) => field.name).join(',');
// Update options when API data changes
useEffect(() => {
const rawKeys: QueryKeyDataSuggestionsProps[] = data?.data?.keys
? Object.values(data.data?.keys).flat()
: [];
const keyNames = (data ?? []).map((field) => field.name);
const search = searchInput.trim().toLowerCase();
const extraMatches = extraKeysSignature
.split(',')
.filter((key) => key.length > 0 && key.toLowerCase().includes(search));
const uniqueKeys = [...new Set([...extraMatches, ...keyNames])];
const keyNames = rawKeys.map((key) => key.name);
const uniqueKeys = [
...new Set(searchInput ? keyNames : ['timestamp', ...keyNames]),
];
const updatedOptions = uniqueKeys.flatMap((key) => [
{ label: `${key} (desc)`, value: `${key}:desc` },
{ label: `${key} (asc)`, value: `${key}:asc` },
]);
setSelectOptions(updatedOptions);
}, [data, searchInput]);
setSelectOptions(
uniqueKeys.flatMap((key) => [
{ label: `${key} (desc)`, value: `${key}:desc` },
{ label: `${key} (asc)`, value: `${key}:asc` },
]),
);
}, [data, searchInput, extraKeysSignature]);
// Handle search input with debounce
const handleSearch = (input: string): void => {

View File

@@ -0,0 +1,169 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { ENVIRONMENT } from 'constants/env';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_FIELD_KEYS,
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
} from 'container/LLMObservability/Explorer/constants';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import ListViewOrderBy from '../ListViewOrderBy';
const seenAI: URLSearchParams[] = [];
const seenGeneric: URLSearchParams[] = [];
const mockAIKeys = (names: string[]): void => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
seenAI.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
},
}),
);
},
),
);
};
const mockGenericKeys = (names: string[]): void => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (req, res, ctx) => {
seenGeneric.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
},
}),
);
}),
);
};
const openDropdown = (): void => {
fireEvent.mouseDown(screen.getByRole('combobox'));
};
const getOptionLabels = (): string[] =>
Array.from(document.querySelectorAll('.ant-select-item-option-content')).map(
(node) => node.textContent ?? '',
);
describe('ListViewOrderBy', () => {
beforeEach(() => {
seenAI.length = 0;
seenGeneric.length = 0;
});
it('reads the ai_observability trace context for an AI query', async () => {
mockAIKeys(['total_tokens']);
render(
<ListViewOrderBy
value="last_activity_time:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>,
);
await waitFor(() => {
expect(seenAI).toHaveLength(1);
});
expect(seenAI[0]?.get('searchText')).toBe('');
expect(seenAI[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
expect(seenGeneric).toHaveLength(0);
});
it('offers the extra keys alongside the ones the endpoint reports', async () => {
mockAIKeys(['total_tokens']);
render(
<ListViewOrderBy
value="last_activity_time:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>,
);
openDropdown();
await waitFor(() => {
expect(getOptionLabels()).toContain('total_tokens (desc)');
});
expect(getOptionLabels()).toContain('last_activity_time (asc)');
});
it('keeps a matching extra key while searching', async () => {
mockAIKeys([]);
render(
<ListViewOrderBy
value="last_activity_time:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>,
);
await waitFor(() => {
expect(seenAI.length).toBeGreaterThan(0);
});
openDropdown();
fireEvent.change(screen.getByRole('combobox'), {
target: { value: 'activity' },
});
await waitFor(() => {
expect(getOptionLabels()).toContain('last_activity_time (desc)');
});
});
it('defaults to timestamp and the generic endpoint', async () => {
mockGenericKeys(['service.name']);
render(
<ListViewOrderBy
value="timestamp:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
/>,
);
await waitFor(() => {
expect(seenGeneric).toHaveLength(1);
});
expect(seenGeneric[0]?.get('signal')).toBe(DataSource.TRACES);
expect(seenGeneric[0]?.get('searchText')).toBe('');
openDropdown();
await waitFor(() => {
expect(getOptionLabels()).toContain('timestamp (desc)');
});
});
});

View File

@@ -12,6 +12,8 @@ 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',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

View File

@@ -106,8 +106,8 @@ export const REACT_QUERY_KEY = {
// Dashboard Grid Card Query Keys
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
// Fields Selector Query Keys
GET_FIELDS_SELECTOR_SUGGESTIONS: 'GET_FIELDS_SELECTOR_SUGGESTIONS',
// Field Keys Suggestion Query Keys
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
// AI Assistant Query Keys
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',

View File

@@ -1,10 +1,12 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import FieldsSelector from 'components/FieldsSelector';
import Controls, { ControlsProps } from 'container/Controls';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
@@ -14,7 +16,10 @@ function TraceExplorerControls({
totalCount,
perPageOptions,
config,
showSizeChanger = true,
fieldKeysConfig,
builderQueryType,
extraFields,
requiredFields,
}: TraceExplorerControlsProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
@@ -44,6 +49,10 @@ function TraceExplorerControls({
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
fieldKeysConfig={fieldKeysConfig}
builderQueryType={builderQueryType}
extraFields={extraFields}
requiredFields={requiredFields}
/>
</>
)}
@@ -57,26 +66,28 @@ function TraceExplorerControls({
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
handleNavigateNext={handleNavigateNext}
handleNavigatePrevious={handleNavigatePrevious}
showSizeChanger={showSizeChanger}
/>
</div>
);
}
TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
requiredFields?: readonly string[];
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
config: null,
fieldKeysConfig: undefined,
builderQueryType: undefined,
extraFields: undefined,
requiredFields: undefined,
};
export default memo(TraceExplorerControls);

View File

@@ -10,6 +10,25 @@
.actionsContainer {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
}
.orderByContainer {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.orderByLabel {
color: var(--muted-foreground);
// Between --periscope-font-size-small (11px) and -base (13px), so literal.
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
display: flex;
align-items: center;
gap: var(--spacing-2);
}

View File

@@ -3,35 +3,45 @@ import {
memo,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ENTITY_VERSION_V5 } from 'constants/app';
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 TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { getTraceLink } from '../ListView/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import TraceExplorerControls from '../Controls';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_DEFAULT_ORDER_BY,
TRACE_VIEW_FIELD_KEYS,
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
} from '../constants';
import { getListViewQuery } from '../explorerUtils';
import { columns, PER_PAGE_OPTIONS } from './configs';
import { PER_PAGE_OPTIONS } from './configs';
import { useTraceViewColumns } from './useTraceViewColumns';
import styles from './TracesView.module.scss';
interface TracesViewProps {
@@ -49,6 +59,16 @@ function TracesView({
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
const {
columns,
selectedFields,
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
} = useTraceViewColumns();
const {
selectedTime: globalSelectedTime,
maxTime,
@@ -60,8 +80,8 @@ function TracesView({
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
[stagedQuery],
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
);
const queryKey = useMemo(
@@ -73,6 +93,7 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
[
globalSelectedTime,
@@ -81,6 +102,7 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
);
@@ -142,27 +164,43 @@ function TracesView({
}
}, [isLoading, isFetching, isError, rows.length]);
const handleOrderChange = useCallback((value: string): void => {
setOrderBy(value);
}, []);
const fieldsSelectorConfig = useMemo(
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
<Typography>
This tab only shows Root Spans. More details
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
{' '}
here
</Typography.Link>
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<div className={styles.orderByContainer}>
<div className={styles.orderByLabel}>
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={handleOrderChange}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>
</div>
<TraceExplorerControls
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={fieldsSelectorConfig}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
requiredFields={requiredFields}
/>
</div>
</div>
@@ -170,10 +208,11 @@ function TracesView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading}
isLoading={isLoading || isColumnsLoading}
isFetching={isFetching}
isError={isError}
error={error}

View File

@@ -0,0 +1,219 @@
/* eslint-disable no-restricted-syntax */
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { useTraceViewColumns } from '../useTraceViewColumns';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const AGGREGATE_KEYS = [
'llm_call_count',
'tool_call_count',
'distinct_tool_count',
'input_tokens',
'output_tokens',
'total_tokens',
'estimated_total_cost',
'max_llm_duration_nano',
];
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
fields.map((field) => field.name);
const columnNames = (columns: { header?: unknown }[]): string[] =>
columns.map((column) => column.header as string);
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
const seenAI: URLSearchParams[] = [];
const mockAggregateKeys = (names: string[]): void => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
seenAI.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
);
},
),
);
};
const renderColumns = async (): Promise<
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
> => {
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
await waitFor(() => {
expect(rendered.result.current.isLoading).toBe(false);
});
return rendered;
};
describe('useTraceViewColumns', () => {
beforeEach(() => {
seenAI.length = 0;
useColumnStore.getState().tables = {};
localStorage.clear();
mockAggregateKeys(AGGREGATE_KEYS);
});
it('reads the aggregates from the trace context of the keys endpoint', async () => {
await renderColumns();
expect(seenAI).toHaveLength(1);
expect(seenAI[0]?.get('searchText')).toBe('');
expect(seenAI[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
});
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'start_time',
'end_time',
'error_count',
'input',
'output',
...AGGREGATE_KEYS,
]);
});
it('selects only the default-visible columns on first render', async () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
]);
});
it('keeps a newly reported aggregate hidden until it is picked', async () => {
mockAggregateKeys(['brand_new_aggregate']);
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
expect(fieldNames(result.current.selectedFields)).not.toContain(
'brand_new_aggregate',
);
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'trace_id' },
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'total_tokens',
]);
});
it('shows a column added back from the pool', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'input',
]);
});
it('keeps the trace id column even when the selection drops it', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'span_count' }]);
});
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
});
it('persists the selection order', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'total_tokens',
'trace_id',
'service.name',
]);
expect(
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
).toStrictEqual([
'trace:total_tokens:float64',
'trace_id',
'resource:service.name',
]);
});
});

View File

@@ -5,18 +5,29 @@ import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
const TRACE_FIELDS = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name' },
{ name: 'duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
] as TelemetryFieldKey[];
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
(field) => ({
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
TRACE_ID_COLUMN_ID,
]);
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
): TableColumnDef<TracesTableRow>[] =>
fields.map((field) => ({
...getFieldColumn(field),
enableRemove: false,
canBeHidden: false,
}),
);
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: field.name !== TRACE_ID_COLUMN_ID,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
canBeHidden: field.name !== TRACE_ID_COLUMN_ID,
}));

View File

@@ -0,0 +1,109 @@
import { useCallback, useEffect, 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 { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import { buildTraceViewColumns, 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);
interface UseTraceViewColumns {
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
},
TRACE_VIEW_BUILDER_QUERY_TYPE,
);
const availableFields = useMemo(
() => mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isFetched) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isFetched, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
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 onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
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));
},
[columns],
);
return {
columns,
selectedFields,
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
};
}

View File

@@ -1,3 +1,6 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const TOOLBAR_VIEWS = {
list: {
name: 'list',
@@ -34,3 +37,29 @@ export const TOOLBAR_VIEWS = {
key: 'clickhouse',
},
};
export const TRACE_VIEW_DEFAULT_ORDER_BY = 'last_activity_time:desc';
/** Display-only: ordering or filtering on one is an error, so the keys endpoint omits them. */
export const TRACE_VIEW_COLUMN_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'root_span_name' },
{ name: 'trace_duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
{ name: 'start_time' },
{ name: 'end_time' },
{ name: 'error_count' },
{ name: 'input' },
{ name: 'output' },
] as TelemetryFieldKey[];
export const TRACE_VIEW_FIELD_KEYS = {
fieldContext: TelemetrytypesFieldContextDTO.trace,
} as const;
export const TRACE_VIEW_BUILDER_QUERY_TYPE = 'builder_ai_query' as const;
export const TRACE_VIEW_ORDER_BY_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'last_activity_time' } as TelemetryFieldKey,
];

View File

@@ -0,0 +1,185 @@
import { QueryClient } from 'react-query';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfig, FieldKeysResponse } from 'api/querySuggestions/types';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
getFieldKeysQueryOptions,
toFieldKeys,
} from '../useFieldKeysSuggestion';
/** Drives the options object the way react-query does, without a client. */
const fetchKeys = async (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
): Promise<TelemetryFieldKey[]> => {
const { queryFn, select } = getFieldKeysQueryOptions(
fieldKeysConfig,
builderQueryType,
);
const response = await (
queryFn as (context: { signal: AbortSignal }) => Promise<FieldKeysResponse>
)({ signal: new AbortController().signal });
return select?.(response) ?? [];
};
const mockKeys = (
path: '/api/v1/ai_observability/fields/keys' | '/api/v1/fields/keys',
names: string[],
onRequest?: (params: URLSearchParams) => void,
): void => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}${path}`, (req, res, ctx) => {
onRequest?.(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
},
}),
);
}),
);
};
describe('useFieldKeysSuggestion', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
const seen: URLSearchParams[] = [];
mockKeys(
'/api/v1/ai_observability/fields/keys',
['total_tokens'],
(params) => {
seen.push(params);
},
);
const keys = await fetchKeys(
{
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: 'llm',
fieldContext: TelemetrytypesFieldContextDTO.trace,
},
'builder_ai_query',
);
expect(seen).toHaveLength(1);
expect(seen[0]?.get('searchText')).toBe('llm');
expect(seen[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
});
it('reads the generic endpoint for an unmarked query', async () => {
const seen: URLSearchParams[] = [];
mockKeys('/api/v1/fields/keys', ['service.name'], (params) => {
seen.push(params);
});
const keys = await fetchKeys({
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: 'svc',
});
expect(seen).toHaveLength(1);
expect(seen[0]?.get('signal')).toBe(DataSource.TRACES);
expect(seen[0]?.get('searchText')).toBe('svc');
expect(keys.map((key) => key.name)).toStrictEqual(['service.name']);
});
it('reads the trace context of the ai_observability endpoint', async () => {
const seen: URLSearchParams[] = [];
mockKeys(
'/api/v1/ai_observability/fields/keys',
['total_tokens'],
(params) => {
seen.push(params);
},
);
const keys = await fetchKeys(
{
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
fieldContext: TelemetrytypesFieldContextDTO.trace,
},
'builder_ai_query',
);
expect(seen[0]?.get('searchText')).toBe('');
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
});
it('reuses the cached keys response for a second empty search', async () => {
const seen: URLSearchParams[] = [];
mockKeys(
'/api/v1/ai_observability/fields/keys',
['total_tokens'],
(params) => {
seen.push(params);
},
);
const fieldKeysConfig = {
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
fieldContext: TelemetrytypesFieldContextDTO.trace,
};
// Built twice: equal keys must resolve to one cache entry, not two requests.
await queryClient.fetchQuery(
getFieldKeysQueryOptions(fieldKeysConfig, 'builder_ai_query'),
);
await queryClient.fetchQuery(
getFieldKeysQueryOptions(fieldKeysConfig, 'builder_ai_query'),
);
expect(seen).toHaveLength(1);
});
it('hands the query signal to the fetcher so a superseded search aborts', async () => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_req, res, ctx) =>
res(ctx.delay(500), ctx.status(200), ctx.json({ status: 'success' })),
),
);
const controller = new AbortController();
const { queryFn } = getFieldKeysQueryOptions({
signal: DATA_SOURCE_TO_SIGNAL[DataSource.LOGS],
searchText: 'svc',
});
const pending = (
queryFn as (context: { signal: AbortSignal }) => Promise<unknown>
)({ signal: controller.signal });
controller.abort();
await expect(pending).rejects.toBeDefined();
});
it('treats a null keys map as empty', () => {
expect(
toFieldKeys({
status: 'success',
data: { complete: false, keys: null },
}),
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,50 @@
import {
QueryKey,
useQuery,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { FieldKeysConfig, FieldKeysResponse } from 'api/querySuggestions/types';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
export type FieldKeysQueryOptions = UseQueryOptions<
FieldKeysResponse,
ErrorType<RenderErrorResponseDTO>,
TelemetryFieldKey[]
> & { queryKey: QueryKey };
export const toFieldKeys = (
res: FieldKeysResponse | undefined,
): TelemetryFieldKey[] =>
Object.values(res?.data?.keys ?? {})
.flat()
.map((key) => ({ ...key }) as TelemetryFieldKey);
export const getFieldKeysQueryOptions = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
): FieldKeysQueryOptions => ({
queryKey: [
REACT_QUERY_KEY.FIELD_KEYS_SUGGESTION,
builderQueryType,
fieldKeysConfig,
],
queryFn: ({ signal }): Promise<FieldKeysResponse> =>
getFieldKeySuggestions(fieldKeysConfig, builderQueryType, signal),
select: toFieldKeys,
staleTime: FIELD_API_CACHE_TIME,
cacheTime: FIELD_API_CACHE_TIME,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
export const useFieldKeysSuggestion = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
useQuery(getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType));

View File

@@ -0,0 +1,44 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { mergeExtraFields } from '../extraFields';
describe('mergeExtraFields', () => {
it('drops fetched keys that share a composite key with an extra field', () => {
expect(
mergeExtraFields(
[{ name: 'trace_id' } as TelemetryFieldKey],
[
{ name: 'trace_id' } as TelemetryFieldKey,
{ name: 'total_tokens' } as TelemetryFieldKey,
],
).map((key) => key.name),
).toStrictEqual(['trace_id', 'total_tokens']);
});
it('keeps extra and fetched keys that share a name but differ in context', () => {
expect(
mergeExtraFields(
[{ name: 'service.name', fieldContext: 'resource' } as TelemetryFieldKey],
[
{
name: 'service.name',
fieldContext: 'attribute',
} as TelemetryFieldKey,
{ name: 'total_tokens' } as TelemetryFieldKey,
],
),
).toStrictEqual([
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'service.name', fieldContext: 'attribute' },
{ name: 'total_tokens' },
]);
});
it('returns the fetched keys when there are no extra fields', () => {
expect(
mergeExtraFields(undefined, [
{ name: 'total_tokens' } as TelemetryFieldKey,
]).map((key) => key.name),
).toStrictEqual(['total_tokens']);
});
});

View File

@@ -0,0 +1,23 @@
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const mergeExtraFields = (
extra: TelemetryFieldKey[] = [],
fetched: TelemetryFieldKey[],
): TelemetryFieldKey[] => {
const extraKeys = new Set(
extra.map((field) =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType),
),
);
return [
...extra,
...fetched.filter(
(field) =>
!extraKeys.has(
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType),
),
),
];
};

View File

@@ -495,8 +495,8 @@ WITH
toDateTime64(%[3]d/1e9, 9) AS start_ts,
toDateTime64(%[4]d/1e9, 9) AS end_ts,
('%[5]s','%[6]s') AS step1,
('%[7]s','%[8]s') AS step2
(%[5]s,%[6]s) AS step1,
(%[7]s,%[8]s) AS step2
SELECT
trace_id,
@@ -527,10 +527,10 @@ LIMIT 5;
containsErrorT2,
startTs,
endTs,
serviceNameT1,
spanNameT1,
serviceNameT2,
spanNameT2,
clickhousesql.StringLiteral(serviceNameT1),
clickhousesql.StringLiteral(spanNameT1),
clickhousesql.StringLiteral(serviceNameT2),
clickhousesql.StringLiteral(spanNameT2),
clauseStep1,
clauseStep2,
t1TimeExpr,
@@ -571,8 +571,8 @@ WITH
toDateTime64(%[3]d/1e9, 9) AS start_ts,
toDateTime64(%[4]d/1e9, 9) AS end_ts,
('%[5]s','%[6]s') AS step1,
('%[7]s','%[8]s') AS step2
(%[5]s,%[6]s) AS step1,
(%[7]s,%[8]s) AS step2
SELECT
trace_id,
@@ -607,10 +607,10 @@ LIMIT 5;
containsErrorT2,
startTs,
endTs,
serviceNameT1,
spanNameT1,
serviceNameT2,
spanNameT2,
clickhousesql.StringLiteral(serviceNameT1),
clickhousesql.StringLiteral(spanNameT1),
clickhousesql.StringLiteral(serviceNameT2),
clickhousesql.StringLiteral(spanNameT2),
clauseStep1,
clauseStep2,
t1TimeExpr,