Compare commits

..

1 Commits

Author SHA1 Message Date
Aditya Singh
375b3f8305 feat(query-builder): move group-by and order-by key suggestions to the v5 fields API (#13127)
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

- The group-by and order-by filters in the query builder still fetched
key suggestions from the v3 `attribute_keys` API. Every explorer, panel
editor and alert form that is otherwise on v5 called it through them.
- Both filters now read `GET /api/v1/fields/keys` through the existing
`useFieldKeysSuggestion` hook. The dropdown rows, badges, chips and the
stored group-by shape are unchanged.
- `container/ExplorerOrderBy` had no importer and is removed.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/158
Part of https://github.com/SigNoz/events-pod/issues/156


### ScreenRecordings



https://github.com/user-attachments/assets/fa907fa3-e6f0-45b6-b171-82e212ccda29



https://github.com/user-attachments/assets/0f6f246a-786e-42c2-b528-4949785ca7e1



https://github.com/user-attachments/assets/933d7495-23af-4f2f-8aca-07693149f85a





#### Additional Information

- A new group-by pick stores `type` from the v5 field context
(`attribute` / `resource`). v3 wrote `tag`. Saved dashboards, alerts and
views are not rewritten.
2026-10-14 19:52:18 +00:00
30 changed files with 498 additions and 600 deletions

View File

@@ -16440,12 +16440,12 @@ paths:
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the gen_ai spans of a trace, model calls, tool calls and
agent spans, in timestamp order. The messages a span carries are decoded into
formatted_input and formatted_output, with formatter naming the converter
and formatter_warnings what it could not resolve; a span without messages
has both empty. Pass nextCursor as after or prevCursor as before to page,
or spanId to open the page around a span.
description: Returns the spans carrying gen_ai input or output messages, or
a tool execution, in timestamp order. Messages already in the OTel GenAI shape
are decoded into formatted_input and formatted_output, with formatter naming
the converter and formatter_warnings what it could not resolve. Pass nextCursor
as after or prevCursor as before to page, or spanId to open the page around
a span.
operationId: GetTraceThread
parameters:
- description: Page size, at most 100. 0 means 20.

View File

@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
};
/**
* Returns the gen_ai spans of a trace, model calls, tool calls and agent spans, in timestamp order. The messages a span carries are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve; a span without messages has both empty. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (

View File

@@ -18,13 +18,11 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
index,
version,
panelType,
signalSource = '',
}: {
query: IBuilderQuery;
index: number;
version: string;
panelType: PANEL_TYPES | null;
signalSource: string;
}): JSX.Element {
const { setAggregationOptions } = useQueryBuilderV2Context();
const {
@@ -206,7 +204,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
disabled={!queryAggregation.metricName}
query={query}
onChange={handleChangeGroupByKeys}
signalSource={signalSource}
/>
</div>
</div>
@@ -243,7 +240,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
disabled={!queryAggregation.metricName}
query={query}
onChange={handleChangeGroupByKeys}
signalSource={signalSource}
/>
</div>
</div>

View File

@@ -316,7 +316,6 @@ export const QueryV2 = forwardRef(function QueryV2(
index={index}
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
version="v4"
signalSource={signalSource as 'meter' | ''}
/>
)}

View File

@@ -1,73 +0,0 @@
import { memo, useMemo } from 'react';
import { Select, Spin } from 'antd';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { StringOperators } from 'types/common/queryBuilder';
function ExplorerOrderBy({ query, onChange }: OrderByFilterProps): JSX.Element {
const {
debouncedSearchText,
selectedValue,
aggregationOptions,
generateOptions,
createOptions,
handleChange,
handleSearchKeys,
} = useOrderByFilter({ query, onChange });
const { data, isFetching } = useGetAggregateKeys(
{
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText: debouncedSearchText,
},
{
keepPreviousData: true,
},
);
const options = useMemo(() => {
const keysOptions = createOptions(data?.payload?.attributeKeys || []);
const customOptions = createOptions([
{ key: 'timestamp', type: '', dataType: DataTypes.EMPTY },
]);
const baseOptions = [
...customOptions,
...(query.aggregateOperator === StringOperators.NOOP
? []
: aggregationOptions),
...keysOptions,
];
return generateOptions(baseOptions);
}, [
aggregationOptions,
createOptions,
data?.payload?.attributeKeys,
generateOptions,
query.aggregateOperator,
]);
return (
<Select
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}
showSearch
showArrow={false}
value={selectedValue}
labelInValue
options={options}
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
/>
);
}
export default memo(ExplorerOrderBy);

View File

@@ -1,18 +1,5 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
// Total, not Partial, so a signal added to the generated enum has to be mapped here
// before it compiles.
const SIGNAL_TO_DATA_SOURCE: Record<
TelemetrytypesSignalDTO,
DataSource | undefined
> = {
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
// The "unset" member: not a data source a query can be built against.
[TelemetrytypesSignalDTO['']]: undefined,
};
import { DataSource, SIGNAL_TO_DATA_SOURCE } from 'types/common/queryBuilder';
export function signalsToDataSources(
signals: readonly TelemetrytypesSignalDTO[],

View File

@@ -5,5 +5,4 @@ export type GroupByFilterProps = {
query: IBuilderQuery;
onChange: (values: BaseAutocompleteData[]) => void;
disabled: boolean;
signalSource?: string;
};

View File

@@ -6,39 +6,37 @@ import {
useMemo,
useState,
} from 'react';
import { useQueryClient } from 'react-query';
import { Select, Spin } from 'antd';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
// ** Constants
import { idDivider, QueryBuilderKeys } from 'constants/queryBuilder';
import { idDivider } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import useDebounce from 'hooks/useDebounce';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
// ** Components
// ** Helpers
import { isEqual, uniqWith } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import {
DATA_SOURCE_TO_SIGNAL,
QUERY_SOURCE_TO_FIELD_SOURCE,
} from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { getMetricNameForQuery } from '../utils';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { GroupByFilterProps } from './GroupByFilter.interfaces';
import { toAttributeKey } from './utils';
export const GroupByFilter = memo(function GroupByFilter({
query,
onChange,
disabled,
signalSource,
}: GroupByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState<string>('');
const [optionsData, setOptionsData] = useState<
SelectOption<string, ReactNode>[]
>([]);
const [localValues, setLocalValues] = useState<SelectOption<string, string>[]>(
[],
);
@@ -46,74 +44,40 @@ export const GroupByFilter = memo(function GroupByFilter({
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
const dataSource = useMemo(() => {
if (signalSource === 'meter') {
return 'meter' as DataSource;
}
return query.dataSource;
}, [signalSource, query.dataSource]);
const { isFetching } = useGetAggregateKeys(
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
{
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource,
aggregateOperator: query.aggregateOperator || '',
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
metricName: getMetricNameForQuery(query),
searchText: debouncedValue,
},
{
queryKey: [debouncedValue, isFocused],
enabled: !disabled && isFocused,
onSuccess: (data) => {
const keys = query.groupBy.reduce<string[]>((acc, item) => {
acc.push(item.key);
return acc;
}, []);
const filteredOptions: BaseAutocompleteData[] =
data?.payload?.attributeKeys?.filter(
(attrKey) => !keys.includes(attrKey.key),
) || [];
const options: SelectOption<string, ReactNode>[] =
filteredOptions.map((item) => ({
label: (
<OptionRenderer
key={item.key}
label={item.key}
value={item.key}
dataType={item.dataType || ''}
type={item.type || ''}
/>
),
value: `${item.id}`,
})) || [];
setOptionsData(options);
},
},
query.builderQueryType,
{ enabled: !disabled && isFocused, keepPreviousData: true },
);
const getAttributeKeys = useCallback(async () => {
const response = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, searchText, isFocused],
async () =>
getAggregateKeys({
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText,
}),
);
const attributeKeys = useMemo(
() => (fieldKeys ?? []).map(toAttributeKey),
[fieldKeys],
);
return response.payload?.attributeKeys || [];
}, [
isFocused,
query.aggregateAttribute?.key,
query.aggregateOperator,
dataSource,
queryClient,
searchText,
]);
const optionsData = useMemo((): SelectOption<string, ReactNode>[] => {
const selectedKeys = new Set(query.groupBy.map((item) => item.key));
return attributeKeys
.filter((item) => !selectedKeys.has(item.key))
.map((item) => ({
label: (
<OptionRenderer
key={item.key}
label={item.key}
value={item.key}
dataType={item.dataType || ''}
type={item.type || ''}
/>
),
value: `${item.id}`,
}));
}, [attributeKeys, query.groupBy]);
const handleSearchKeys = (searchText: string): void => {
setSearchText(searchText);
@@ -129,15 +93,13 @@ export const GroupByFilter = memo(function GroupByFilter({
};
const handleChange = useCallback(
async (values: SelectOption<string, string>[]): Promise<void> => {
const keys = await getAttributeKeys();
(values: SelectOption<string, string>[]): void => {
const groupByValues: BaseAutocompleteData[] = values.map((item) => {
const id = item.value;
const currentValue = item.value.split(idDivider)[0];
if (id && id.includes(idDivider)) {
const attribute = keys.find((item) => item.id === id);
const attribute = attributeKeys.find((item) => item.id === id);
const existAttribute = query.groupBy.find((item) => item.id === id);
if (attribute) {
@@ -149,14 +111,14 @@ export const GroupByFilter = memo(function GroupByFilter({
}
}
return chooseAutocompleteFromCustomValue(keys, currentValue);
return chooseAutocompleteFromCustomValue(attributeKeys, currentValue);
});
const result = uniqWith(groupByValues, isEqual);
onChange(result);
},
[getAttributeKeys, onChange, query.groupBy],
[attributeKeys, onChange, query.groupBy],
);
const clearSearch = useCallback(() => {

View File

@@ -9,137 +9,140 @@ import { IBuilderQuery } from '../../../../../types/api/queryBuilder/queryBuilde
import { DataSource } from '../../../../../types/common/queryBuilder';
import { GroupByFilter } from '../GroupByFilter';
const BASE_URL = ENVIRONMENT.baseURL;
const attributeKeysURL = `${BASE_URL}/api/v3/autocomplete/attribute_keys`;
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
function setup(
overrides?: Partial<React.ComponentProps<typeof GroupByFilter>>,
): { onChange: jest.Mock } {
type FieldKeyVariant = {
name: string;
fieldContext: string;
fieldDataType?: string;
};
const fieldKeysBody = (
variants: FieldKeyVariant[],
): Record<string, unknown> => ({
status: 'success',
data: {
complete: true,
keys: variants.reduce<Record<string, FieldKeyVariant[]>>(
(acc, variant) => ({
...acc,
[variant.name]: [...(acc[variant.name] ?? []), variant],
}),
{},
),
},
});
const groupByInput = (): HTMLInputElement =>
screen.getByTestId('group-by').querySelector('input') as HTMLInputElement;
function setup(queryOverrides?: Partial<IBuilderQuery>): {
onChange: jest.Mock;
} {
const onChange = jest.fn();
const query = {
dataSource: DataSource.METRICS,
aggregateAttribute: { key: 'service.name' },
aggregateAttribute: { key: 'signoz_calls_total' },
aggregateOperator: 'COUNT',
groupBy: [],
...queryOverrides,
} as unknown as IBuilderQuery;
render(
<GroupByFilter
query={query}
onChange={onChange}
disabled={false}
signalSource="meter"
{...overrides}
/>,
);
render(<GroupByFilter query={query} onChange={onChange} disabled={false} />);
return { onChange };
}
// wait for dropdown and select the option instead of hitting Enter
async function clickOption(label: string): Promise<void> {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const option = await screen.findByText(
(content, el) =>
content.trim() === label && !!el?.closest('.ant-select-item-option'),
);
await user.click(option);
}
describe('GroupByFilter', () => {
const dataSourceCalls: string[] = [];
let callCount = 0;
const requests: URLSearchParams[] = [];
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
dataSourceCalls.length = 0;
callCount = 0;
requests.length = 0;
jest.clearAllMocks();
});
afterAll(() => {
server.close();
});
it('uses meter datasource for suggestions and normalization', async () => {
function useFieldKeys(variants: FieldKeyVariant[]): void {
server.use(
rest.get(attributeKeysURL, (req, res, ctx) => {
const ds = req.url.searchParams.get('dataSource') || '';
dataSourceCalls.push(ds);
callCount += 1;
const attributeKeys =
callCount === 1
? [
{
id: 'service.name--string--',
key: 'service.name',
dataType: 'string',
type: '',
},
]
: [];
return res(
ctx.status(200),
ctx.json({
data: { attributeKeys },
}),
);
rest.get(fieldKeysURL, (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(ctx.status(200), ctx.json(fieldKeysBody(variants)));
}),
);
}
const { onChange } = setup();
it('asks the fields API for meter keys and emits a typed custom key', async () => {
useFieldKeys([]);
const { onChange } = setup({ source: 'meter' });
const select = screen.getByTestId('group-by');
fireEvent.focus(select);
fireEvent.focus(screen.getByTestId('group-by'));
await waitFor(() => expect(dataSourceCalls.length).toBeGreaterThanOrEqual(1));
expect(dataSourceCalls[0]).toBe('meter');
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
expect(requests[0].get('signal')).toBe('metrics');
expect(requests[0].get('source')).toBe('meter');
expect(requests[0].get('metricName')).toBe('signoz_calls_total');
const input = select.querySelector('input') as HTMLInputElement;
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.type(input, 'custom.attr');
// wait for dropdown and select the option instead of hitting Enter
await screen.findByRole('listbox');
const option = await screen.findByRole('option', { name: 'custom.attr' });
await user.click(option);
await user.type(groupByInput(), 'custom.attr');
await clickOption('custom.attr');
expect(onChange).toHaveBeenCalled();
expect(dataSourceCalls[dataSourceCalls.length - 1]).toBe('meter');
const emitted = onChange.mock.calls[0][0][0];
expect(emitted.key).toBe('custom.attr');
expect(emitted.id).toBeTruthy();
expect(emitted.id).not.toBe('----');
});
it('clicks suggested option and emits proper value', async () => {
server.use(
rest.get(attributeKeysURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
attributeKeys: [
{
id: 'service.name--string--',
key: 'service.name',
dataType: 'string',
type: '',
},
],
},
}),
),
),
);
it('emits the picked suggestion with its context and data type', async () => {
useFieldKeys([
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
]);
const { onChange } = setup();
const combo = screen.getByRole('combobox');
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(combo);
await user.click(groupByInput());
await clickOption('service.name');
await screen.findByRole('listbox');
const option = await screen.findByText(
(content, el) =>
content.trim() === 'service.name' &&
!!el?.closest('.ant-select-item-option'),
);
await user.click(option);
// Expect payload to be resolved to the exact attribute returned by MSW
await waitFor(() => expect(onChange).toHaveBeenCalled());
const emitted = onChange.mock.calls[0][0][0];
expect(emitted.key).toBe('service.name');
expect(emitted.id).toBe('service.name--string--');
// Expect payload to be resolved to the exact attribute returned by MSW
expect(onChange.mock.calls[0][0][0]).toMatchObject({
key: 'service.name',
type: 'resource',
dataType: 'string',
id: 'service.name--string--resource',
});
});
it('offers each context of a name as its own option', async () => {
useFieldKeys([
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
{ name: 'service.name', fieldContext: 'attribute', fieldDataType: 'string' },
]);
setup();
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(groupByInput());
await waitFor(() =>
expect(
screen
.getAllByText('service.name')
.filter((el) => !!el.closest('.ant-select-item-option')),
).toHaveLength(2),
);
});
});

View File

@@ -0,0 +1,24 @@
import {
baseAutoCompleteIdKeysOrder,
initialAutocompleteData,
} from 'constants/queryBuilder';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
export const toAttributeKey = (
key: TelemetryFieldKey,
): BaseAutocompleteData => {
const fields = {
key: key.name,
dataType: fieldDataTypeToDataType(key.fieldDataType),
type: key.fieldContext ?? '',
};
return {
...initialAutocompleteData,
...fields,
id: createIdFromObjectFields(fields, baseAutoCompleteIdKeysOrder),
};
};

View File

@@ -1,11 +1,17 @@
import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
DATA_SOURCE_TO_SIGNAL,
DataSource,
MetricAggregateOperator,
QUERY_SOURCE_TO_FIELD_SOURCE,
} from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { getMetricNameForQuery } from '../utils';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
import { useOrderByFilter } from './useOrderByFilter';
@@ -27,13 +33,14 @@ export function OrderByFilter({
handleSearchKeys,
} = useOrderByFilter({ query, onChange, entityVersion });
const { data, isFetching } = useGetAggregateKeys(
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
{
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
metricName: getMetricNameForQuery(query),
searchText: debouncedSearchText,
},
query.builderQueryType,
{
enabled: !!query.aggregateAttribute?.key || isRawQuery,
keepPreviousData: true,
@@ -47,9 +54,11 @@ export function OrderByFilter({
);
const optionsData = useMemo(() => {
const keyOptions = createOptions(data?.payload?.attributeKeys || []);
const groupByOptions = createOptions(query.groupBy);
const aggregationOptionsFromParsed = createOptions(parsedAggregationOptions);
const keyOptions = createOptions((fieldKeys ?? []).map((key) => key.name));
const groupByOptions = createOptions(query.groupBy.map((item) => item.key));
const aggregationOptionsFromParsed = createOptions(
parsedAggregationOptions.map((item) => item.key),
);
const options =
query.aggregateOperator === MetricAggregateOperator.NOOP
@@ -62,7 +71,7 @@ export function OrderByFilter({
return generateOptions(options);
}, [
createOptions,
data?.payload?.attributeKeys,
fieldKeys,
query.groupBy,
query.aggregateOperator,
parsedAggregationOptions,
@@ -92,6 +101,7 @@ export function OrderByFilter({
options={optionsData}
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
data-testid="order-by"
/>
);
}

View File

@@ -0,0 +1,85 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilter } from '../OrderByFilter';
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
const fieldKeysBody = (names: string[]): Record<string, unknown> => ({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [name, [{ name, fieldContext: 'resource' }]]),
),
},
});
const orderByInput = (): HTMLInputElement =>
screen.getByTestId('order-by').querySelector('input') as HTMLInputElement;
function setup(query: Partial<IBuilderQuery>): { onChange: jest.Mock } {
const onChange = jest.fn();
render(
<OrderByFilter
query={
{
dataSource: DataSource.LOGS,
aggregateOperator: 'noop',
aggregateAttribute: { key: '' },
groupBy: [],
orderBy: [],
aggregations: [{ expression: 'count()' }],
...query,
} as unknown as IBuilderQuery
}
onChange={onChange}
isRawQuery
isNewQueryV2
/>,
);
return { onChange };
}
describe('OrderByFilter', () => {
const requests: URLSearchParams[] = [];
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
requests.length = 0;
jest.clearAllMocks();
});
afterAll(() => {
server.close();
});
it('offers fields API keys in both directions for a raw query', async () => {
server.use(
rest.get(fieldKeysURL, (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(ctx.status(200), ctx.json(fieldKeysBody(['service.name'])));
}),
);
const { onChange } = setup({});
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(orderByInput());
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
expect(requests[0].get('signal')).toBe('logs');
await screen.findByText('service.name desc');
await user.click(await screen.findByText('service.name asc'));
expect(onChange).toHaveBeenCalledWith([
{ columnName: 'service.name', order: 'asc' },
]);
});
});

View File

@@ -4,7 +4,6 @@ import useDebounce from 'hooks/useDebounce';
import { IOption } from 'hooks/useResourceAttribute/types';
import { isEqual, uniqWith } from 'lodash-es';
import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
@@ -25,7 +24,7 @@ export type UseOrderByFilterResult = {
selectedValue: IOption[];
aggregationOptions: IOption[];
generateOptions: (options: IOption[]) => IOption[];
createOptions: (data: BaseAutocompleteData[]) => IOption[];
createOptions: (keys: string[]) => IOption[];
handleChange: (values: IOption[]) => void;
handleSearchKeys: (search: string) => void;
};
@@ -121,7 +120,7 @@ export const useOrderByFilter = ({
};
const createOptions = useCallback(
(data: BaseAutocompleteData[]): IOption[] => mapLabelValuePairs(data).flat(),
(keys: string[]): IOption[] => mapLabelValuePairs(keys).flat(),
[],
);

View File

@@ -1,6 +1,5 @@
import { IOption } from 'hooks/useResourceAttribute/types';
import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
OrderByPayload,
@@ -34,11 +33,8 @@ export const transformToOrderByStringValues = (
return prepareSelectedValue;
};
export function mapLabelValuePairs(
arr: BaseAutocompleteData[],
): Array<IOption>[] {
return arr.map((item) => {
const value = item.key;
export function mapLabelValuePairs(keys: string[]): Array<IOption>[] {
return keys.map((value) => {
return [
{
label: `${value} ${ORDERBY_FILTERS.ASC}`,

View File

@@ -3,7 +3,12 @@ import { HAVING_FILTER_REGEXP } from 'constants/regExp';
import { IOption } from 'hooks/useResourceAttribute/types';
import uniqWith from 'lodash-es/unionWith';
import { parse } from 'papaparse';
import { HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import {
HavingForm,
IBuilderQuery,
} from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { ORDERBY_FILTERS } from './OrderByFilter/config';
import {
@@ -111,3 +116,17 @@ export const filterByOperatorConfig = (
OPERATORS_CONFIG[key].includes(option.label),
);
};
export const getMetricNameForQuery = (
query: IBuilderQuery,
): string | undefined => {
if (query.dataSource !== DataSource.METRICS) {
return undefined;
}
return (
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
undefined
);
};

View File

@@ -43,8 +43,16 @@ export const getFieldKeysQueryOptions = (
refetchOnWindowFocus: false,
});
export type FieldKeysQueryOverrides = Partial<
Omit<FieldKeysQueryOptions, 'queryKey' | 'queryFn' | 'select'>
>;
export const useFieldKeysSuggestion = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
options?: FieldKeysQueryOverrides,
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
useQuery(getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType));
useQuery({
...getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType),
...options,
});

View File

@@ -1,10 +1,61 @@
import type { GetFieldsKeys200 } from 'api/generated/services/sigNoz.schemas';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest } from 'msw';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource, SIGNAL_TO_DATA_SOURCE } from 'types/common/queryBuilder';
import {
autocompleteKeysResponse,
autocompleteValuesResponse,
} from './__story_mockdata__/queryBuilderAutocomplete';
/**
* The v5 keys list, served from the same per-signal fixture as the v3 pair so
* the group-by and order-by filters open on the product's keys. A context
* prefix in `searchText` narrows to that context the way the backend does.
*/
const fieldKeysResponseForSignal = (
signal: string | null,
searchText: string,
): GetFieldsKeys200 => {
const context = Object.values(TelemetrytypesFieldContextDTO).find(
(ctx) => ctx && searchText.toLowerCase().startsWith(`${ctx}.`),
);
const keys = (
autocompleteKeysResponse(
SIGNAL_TO_DATA_SOURCE[signal as TelemetrytypesSignalDTO] ?? DataSource.LOGS,
context ? searchText.slice(context.length + 1) : searchText,
).data.attributeKeys ?? []
).map((attribute) => ({
name: attribute.key,
signal: signal as TelemetrytypesSignalDTO,
fieldContext:
attribute.type === 'resource'
? TelemetrytypesFieldContextDTO.resource
: TelemetrytypesFieldContextDTO.attribute,
fieldDataType: (attribute.dataType === DataTypes.Int64 ||
attribute.dataType === DataTypes.Float64
? TelemetrytypesFieldDataTypeDTO.number
: attribute.dataType) as TelemetrytypesFieldDataTypeDTO,
}));
return {
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
keys
.filter((key) => !context || key.fieldContext === context)
.map((key) => [key.name, [key]]),
),
},
};
};
/**
* The query shapes `src/mocks-server/handlers.ts` answers from its own fixture:
* the APM resource attribute filter, the one case its two autocomplete handlers
@@ -37,6 +88,17 @@ const isSharedFixtureValuesQuery = (params: URLSearchParams): boolean => {
* both. Returning nothing hands the request on to the next matching handler.
*/
export const queryBuilderHandlers = [
rest.get('http://localhost/api/v1/fields/keys', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(
fieldKeysResponseForSignal(
req.url.searchParams.get('signal'),
req.url.searchParams.get('searchText') ?? '',
),
),
),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
(req, res, ctx) => {

View File

@@ -6,6 +6,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
import { Format } from 'constants/formats/types';
import { EQueryType } from 'types/common/dashboard';
import {
BuilderQuerySource,
DataSource,
QueryBuilderData,
ReduceOperators,
@@ -104,7 +105,7 @@ export type IBuilderQuery = {
pageSize?: number;
offset?: number;
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
source?: 'meter' | '';
source?: BuilderQuerySource;
builderQueryType?: BuilderQueryType;
/** Heatmap only. */
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;

View File

@@ -1,5 +1,8 @@
import { Dispatch, SetStateAction } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
TelemetrytypesSignalDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Format } from 'constants/formats/types';
@@ -32,6 +35,31 @@ export const DATA_SOURCE_TO_SIGNAL: Record<
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
};
// Total, not Partial, so a signal added to the generated enum has to be mapped here
// before it compiles.
export const SIGNAL_TO_DATA_SOURCE: Record<
TelemetrytypesSignalDTO,
DataSource | undefined
> = {
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
// The "unset" member: not a data source a query can be built against.
[TelemetrytypesSignalDTO['']]: undefined,
};
export type BuilderQuerySource = `${TelemetrytypesSourceDTO}`;
// `source` tells meter and metrics apart, since both share the metrics signal:
// meter sends 'meter', the rest is ''.
export const QUERY_SOURCE_TO_FIELD_SOURCE: Record<
BuilderQuerySource,
TelemetrytypesSourceDTO | undefined
> = {
meter: TelemetrytypesSourceDTO.meter,
'': undefined,
};
export enum StringOperators {
NOOP = 'noop',
COUNT = 'count',

View File

@@ -90,7 +90,7 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
ID: "GetTraceThread",
Tags: []string{"tracedetail"},
Summary: "Get thread view for a trace",
Description: "Returns the gen_ai spans of a trace, model calls, tool calls and agent spans, in timestamp order. The messages a span carries are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve; a span without messages has both empty. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
Description: "Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
RequestQuery: new(spantypes.GetTraceThreadParams),
Response: new(spantypes.GettableTraceThread),
ResponseContentType: "application/json",

View File

@@ -297,11 +297,11 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
sb.SelectMore("attributes")
}
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAISpanGateKeys)
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAIThreadKeys)
if err != nil {
return nil, err
}
isThreadSpan, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
isThreadSpan, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAIThreadKeys, keys, sb)
if err != nil {
return nil, err
}

View File

@@ -220,14 +220,14 @@ func TestGetThreadSpans(t *testing.T) {
name: "FlagOff_ReadsAndFiltersLegacyMaps",
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL,
whereSQL: "(mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))",
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages') OR mapContains(attributes_string, 'gen_ai.tool.name'))",
},
{
name: "FlagOn_ReleasedDuringTrace_ReadsJSONFiltersJSONThenMaps",
jsonOn: true,
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL + ", attributes",
whereSQL: "(multiIf(attributes.`gen_ai.request.model` IS NOT NULL, attributes.`gen_ai.request.model`::String, mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.agent.name` IS NOT NULL, attributes.`gen_ai.agent.name`::String, mapContains(attributes_string, 'gen_ai.agent.name'), attributes_string['gen_ai.agent.name'], NULL) IS NOT NULL)",
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL)",
},
}

View File

@@ -19,10 +19,6 @@ const (
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
GenAIToolCallID = "gen_ai.tool.call.id"
GenAIToolCallArguments = "gen_ai.tool.call.arguments"
GenAIToolCallResult = "gen_ai.tool.call.result"
)
// Per-span costs the SigNoz LLM pricing processor attaches; SigNoz semconv
@@ -36,10 +32,12 @@ const (
)
// GenAISpanGateKeys mark a span as gen_ai: an LLM call, a tool call, or an
// agent span. A trace belongs to the AI explorer when any span carries one,
// and the thread of a trace is these spans.
// agent span. A trace belongs to the AI explorer when any span carries one.
var GenAISpanGateKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
// GenAIThreadKeys mark a span as a thread row: a model call with its messages, or a tool execution.
var GenAIThreadKeys = []string{GenAIInputMessages, GenAIOutputMessages, GenAIToolName}
// GenAISpanFilterExpression renders the gate as a query-builder filter
// expression: each gate key ORed on EXISTS.
func GenAISpanFilterExpression() string {

View File

@@ -11,7 +11,6 @@ import (
const (
FormatterSemconv = "semconv"
FormatterTool = "tool"
FormatterOpenAIChat = "openai.chat"
FormatterText = "text"
FormatterGeneric = "generic"
@@ -35,42 +34,19 @@ type FormattedMessages struct {
Warnings []string
}
// FormatMessages builds the OTel GenAI view of a span's messages from the formats SDKs write. attributes
// is the span's JSON attribute document, nested by key segment, so an object value is whole. Each
// message value is a JSON string or a structured value. A tool call and its result take precedence over
// messages beside them.
func FormatMessages(attributes map[string]any) FormattedMessages {
f := &formatting{attributes: attributes, out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
switch {
case attribute(f.attributes, GenAIToolCallArguments) != nil || attribute(f.attributes, GenAIToolCallResult) != nil:
f.tool()
case attribute(f.attributes, GenAIInputMessages) != nil || attribute(f.attributes, GenAIOutputMessages) != nil:
f.messages()
}
return f.out
}
type formatting struct {
attributes map[string]any
out FormattedMessages
}
func (f *formatting) warn(format string, args ...any) {
f.out.Warnings = append(f.out.Warnings, fmt.Sprintf(format, args...))
}
// messages converts gen_ai.input.messages and gen_ai.output.messages, each by its own shape, and names
// the formatter after the input side, or the output side when the input has none.
func (f *formatting) messages() {
// FormatMessages builds the OTel GenAI view of gen_ai.input.messages and gen_ai.output.messages from the
// formats SDKs write. Each value is a JSON string or a structured value; nil means absent.
func FormatMessages(input, output any) FormattedMessages {
f := &formatting{out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
var inputLabel, outputLabel string
if input := attribute(f.attributes, GenAIInputMessages); input != nil {
if input != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(input), RoleUser)
for _, m := range msgs {
f.out.Input = append(f.out.Input, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
}
}
if output := attribute(f.attributes, GenAIOutputMessages); output != nil {
if output != nil {
f.out.Output, outputLabel = f.side(decode(output), RoleAssistant)
}
f.out.Formatter = inputLabel
@@ -79,6 +55,15 @@ func (f *formatting) messages() {
} else if outputLabel != "" && outputLabel != inputLabel {
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
}
return f.out
}
type formatting struct {
out FormattedMessages
}
func (f *formatting) warn(format string, args ...any) {
f.out.Warnings = append(f.out.Warnings, fmt.Sprintf(format, args...))
}
// side converts one attribute value by its shape; role is what a bare string on this side is
@@ -112,21 +97,6 @@ func (f *formatting) side(value any, role Role) (OutputMessages, string) {
return OutputMessages{genericMessage(value)}, FormatterGeneric
}
// attribute walks the document along the dotted key; a nested object comes back whole. Not handled: a
// scalar at a prefix of the path, such as gen_ai.input beside gen_ai.input.messages, which ends the
// walk early.
func attribute(doc map[string]any, key string) any {
var current any = doc
for segment := range strings.SplitSeq(key, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current = object[segment]
}
return current
}
func firstObject(list []any) (map[string]any, bool) {
if len(list) == 0 {
return nil, false

View File

@@ -2,8 +2,6 @@ package aiobservabilitytypes
import (
"encoding/json"
"maps"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -14,7 +12,6 @@ type formatCase struct {
name string
input any
output any
attributes map[string]any // flat dotted keys beside the two message attributes
formatter string
warnings []string
wantInput string
@@ -25,9 +22,7 @@ func runFormatCases(t *testing.T, testCases []formatCase) {
t.Helper()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attributes := map[string]any{GenAIInputMessages: testCase.input, GenAIOutputMessages: testCase.output}
maps.Copy(attributes, testCase.attributes)
formatted := FormatMessages(document(attributes))
formatted := FormatMessages(testCase.input, testCase.output)
assert.Equal(t, testCase.formatter, formatted.Formatter)
assert.Equal(t, append([]string{}, testCase.warnings...), formatted.Warnings)
@@ -41,25 +36,6 @@ func runFormatCases(t *testing.T, testCases []formatCase) {
}
}
// document nests flat dotted keys the way the JSON column stores them.
func document(flat map[string]any) map[string]any {
doc := map[string]any{}
for key, value := range flat {
segments := strings.Split(key, ".")
current := doc
for _, segment := range segments[:len(segments)-1] {
child, ok := current[segment].(map[string]any)
if !ok {
child = map[string]any{}
current[segment] = child
}
current = child
}
current[segments[len(segments)-1]] = value
}
return doc
}
func TestFormat(t *testing.T) {
runFormatCases(t, []formatCase{
{
@@ -117,8 +93,7 @@ So, the ball costs 5 cents.`,
wantOutput: `[]`,
},
{
name: "NoMessageAttributes_EmptyListsNoFormatter",
attributes: map[string]any{GenAIRequestModel: "gpt-4o", GenAIToolName: "get_weather"},
name: "NoAttributes_EmptyListsNoFormatter",
wantInput: `[]`,
wantOutput: `[]`,
},

View File

@@ -1,69 +0,0 @@
package aiobservabilitytypes
// Format: gen_ai.tool.call.arguments and gen_ai.tool.call.result on an execute_tool span, each an
// object or its JSON string (open-telemetry/semantic-conventions-genai@06ec68e, model/gen-ai/registry.yaml).
// Written by: opentelemetry-instrumentation-langchain, traceloop @tool, OpenInference TOOL spans and
// Vercel ai.toolCall.* through the span mapper.
// Envelopes unwrapped: LangChain's callback payloads, {input_str, tags, metadata, inputs, kwargs} and
// {output, kwargs} (openllmetry callback_handler.py on_tool_start/on_tool_end), and the traceloop
// decorator's {args, kwargs} (traceloop-sdk decorators/base.py _handle_span_input).
// tool renders the call as an assistant tool_call and the result as a tool tool_call_response, both
// carrying gen_ai.tool.call.id so the pair can be matched.
func (f *formatting) tool() {
id := optionalString(attribute(f.attributes, GenAIToolCallID))
if raw := attribute(f.attributes, GenAIToolCallArguments); raw != nil {
call := ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(attribute(f.attributes, GenAIToolName)), ID: id, Arguments: toolArguments(decode(raw))}
f.out.Input = InputMessages{{Role: RoleAssistant, Parts: Parts{part(call)}}}
}
if raw := attribute(f.attributes, GenAIToolCallResult); raw != nil {
result := ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: id, Response: toolResult(decode(raw))}
f.out.Output = OutputMessages{{Role: RoleTool, Parts: Parts{part(result)}}}
}
f.out.Formatter = FormatterTool
}
func toolArguments(value any) any {
m, ok := value.(map[string]any)
if !ok {
return value
}
if _, isLangChain := m["input_str"]; isLangChain && hasKeys(m, "inputs", "kwargs") {
if inputs := m["inputs"]; inputs != nil {
return inputs
}
return decode(m["input_str"])
}
if len(m) == 2 && hasKeys(m, "args", "kwargs") {
args, _ := m["args"].([]any)
kwargs, _ := m["kwargs"].(map[string]any)
switch {
case len(args) == 0:
return m["kwargs"]
case len(kwargs) == 0:
return args
}
}
return value
}
// toolResult unwraps the LangChain envelope; the traceloop decorator encodes a tool returning
// nothing as the literal null.
func toolResult(value any) any {
if m, ok := value.(map[string]any); ok && len(m) == 2 && hasKeys(m, "output", "kwargs") {
return decode(m["output"])
}
if value == "null" {
return nil
}
return value
}
func hasKeys(m map[string]any, keys ...string) bool {
for _, key := range keys {
if _, ok := m[key]; !ok {
return false
}
}
return true
}

View File

@@ -1,110 +0,0 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the tool span attributes as the collector stored them for the SigNoz/scripts
// static-telemetry-generator captures, after the span mapper filled the gen_ai.tool.call.* keys.
func TestFormat_Tool(t *testing.T) {
runFormatCases(t, []formatCase{
{
// langchain capture, execute_tool get_weather span; opentelemetry-instrumentation-langchain
// callback_handler.py on_tool_start/on_tool_end envelopes
name: "LangChainEnvelopes_InputsAndOutputUnwrapped",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallArguments: `{"input_str": "{'city': 'Bengaluru'}", "tags": [], "metadata": {}, "inputs": {"city": "Bengaluru"}, "kwargs": {"color": "green", "name": null, "tool_call_id": null}}`,
GenAIToolCallResult: `{"output": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}", "kwargs": {"tags": [], "color": "green", "name": "get_weather"}}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}]`,
},
{
// openllmetry capture, get_weather.tool span; traceloop-sdk decorators/base.py
// _handle_span_input {args, kwargs} and the result encoded as a JSON string twice
name: "TraceloopDecorator_KwargsUnwrapped_ResultDecodedTwice",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallArguments: `{"args": [], "kwargs": {"city": "Bengaluru"}}`,
GenAIToolCallResult: `"{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}}]}]`,
},
{
// a decorated function returning None is json.dumps(None); the openllmetry capture shows it on
// workflow spans, with this positional-args input
name: "TraceloopDecorator_PositionalArgsOnly_ListKept",
attributes: map[string]any{
GenAIToolName: "lookup",
GenAIToolCallArguments: `{"args": ["OpenAI", "What's the weather in Paris?"], "kwargs": {}}`,
GenAIToolCallResult: `null`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "lookup", "arguments": ["OpenAI", "What's the weather in Paris?"]}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": null}]}]`,
},
{
name: "TraceloopDecorator_BothArgsAndKwargs_EnvelopeKept",
attributes: map[string]any{
GenAIToolName: "lookup",
GenAIToolCallArguments: `{"args": ["Paris"], "kwargs": {"unit": "c"}}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "lookup", "arguments": {"args": ["Paris"], "kwargs": {"unit": "c"}}}]}]`,
wantOutput: `[]`,
},
{
// openinference-openai-agents capture, get_weather TOOL span: input.value and output.value
// copied by the mapper (Arize-ai/openinference@12f2cc03, spec/traces.md)
name: "OpenInferenceToolSpan_PlainJSONAndText",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallArguments: `{"city":"Bangalore"}`,
GenAIToolCallResult: `27C, partly cloudy, light breeze from the south.`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Bangalore"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": "27C, partly cloudy, light breeze from the south."}]}]`,
},
{
// vercel capture, ai.toolCall span: ai.toolCall.{id,name,args,result} copied by the mapper
// (https://ai-sdk.dev/docs/ai-sdk-core/telemetry)
name: "VercelToolCall_IDOnBothParts",
attributes: map[string]any{
GenAIToolName: "get_current_weather",
GenAIToolCallID: "call_l0ycKcmt1uPCdGWR4SkwoLBv",
GenAIToolCallArguments: `{"city":"Bengaluru","unit":"c"}`,
GenAIToolCallResult: `{"city":"Bengaluru","unit":"c","temperature":27,"condition":"partly cloudy"}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_l0ycKcmt1uPCdGWR4SkwoLBv", "arguments": {"city": "Bengaluru", "unit": "c"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_l0ycKcmt1uPCdGWR4SkwoLBv", "response": {"city": "Bengaluru", "unit": "c", "temperature": 27, "condition": "partly cloudy"}}]}]`,
},
{
// the object form the OTel registry asks for, whole in the JSON column
name: "StructuredArguments_ObjectKept",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallID: "call_1",
GenAIToolCallArguments: map[string]any{"city": "Paris", "days": []any{1.0, 2.0}},
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris", "days": [1, 2]}}]}]`,
wantOutput: `[]`,
},
{
// the mapper groups never fill both; the tool call wins when an SDK does
name: "MessagesBesideToolCall_ToolCallFormatted",
attributes: map[string]any{
GenAIInputMessages: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
GenAIToolName: "lookup",
GenAIToolCallArguments: `{"city": "Paris"}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "lookup", "arguments": {"city": "Paris"}}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"maps"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
@@ -179,9 +180,7 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
maps.Copy(resources, storable.ResourcesString)
timeUnixNano := uint64(storable.StartTime.UnixNano())
attributes := threadAttributes(storable)
// Spans from before the JSON rollout have no document and are not decoded: the legacy maps
// hold an object split into one key per field.
formatted := aiobservabilitytypes.FormatMessages(storable.AttributesJSON)
formatted := aiobservabilitytypes.FormatMessages(rawAttribute(storable, aiobservabilitytypes.GenAIInputMessages), rawAttribute(storable, aiobservabilitytypes.GenAIOutputMessages))
return &ThreadSpan{
SpanID: storable.SpanID,
TraceID: traceID,
@@ -215,3 +214,20 @@ func threadAttributes(storable *StorableSpan) map[string]any {
storable.AttributesJSON.FlattenInto("", attributes)
return attributes
}
// rawAttribute reads one attribute for decoding from the JSON document, where an object value is
// still whole. The legacy maps hold objects split into one key per field, so spans from before
// the JSON rollout are not decoded. Not handled: a list of JSON strings, which formats as generic,
// and a scalar at a prefix of the path, such as gen_ai.input beside gen_ai.input.messages, which
// ends the walk early.
func rawAttribute(storable *StorableSpan, key string) any {
var current any = map[string]any(storable.AttributesJSON)
for segment := range strings.SplitSeq(key, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current = object[segment]
}
return current
}

View File

@@ -73,3 +73,35 @@ func TestThreadAttributes(t *testing.T) {
})
}
}
func TestRawAttribute(t *testing.T) {
arguments := map[string]any{"city": "Paris", "days": []any{1.0, 2.0}}
testCases := []struct {
name string
storable StorableSpan
key string
want any
}{
{
name: "JSONColumn_ObjectReturnedWhole",
storable: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"tool": map[string]any{"call": map[string]any{"arguments": arguments}}}}},
key: "gen_ai.tool.call.arguments",
want: arguments,
},
{
name: "JSONColumn_MissingPath_Nil",
storable: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"tool": map[string]any{"name": "get_weather"}}}},
key: "gen_ai.tool.call.arguments",
},
{
name: "LegacyMapsOnly_Nil",
storable: StorableSpan{AttributesString: map[string]string{"gen_ai.output.messages": "sunny", "gen_ai.tool.call.arguments.city": "Paris"}},
key: "gen_ai.output.messages",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, rawAttribute(&testCase.storable, testCase.key))
})
}
}

View File

@@ -11,7 +11,7 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.traces import ATTRIBUTE_JSON_ROLLOUT_TIME, TraceIdGenerator, Traces, TracesKind
def test_thread_returns_gen_ai_spans_in_order(
def test_thread_returns_message_spans_in_order(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
@@ -54,14 +54,10 @@ def test_thread_returns_gen_ai_spans_in_order(
assert response.status_code == HTTPStatus.OK, response.text
thread = response.json()["data"]
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, tool_id, agent_id, second_llm_id, third_llm_id]
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, tool_id, second_llm_id, third_llm_id]
assert "nextCursor" not in thread
first, tool, agent, input_only, output_only = thread["spans"]
assert agent["attributes"]["gen_ai.agent.name"] == "planner"
assert agent["formatter"] == ""
assert agent["formatted_input"] == []
assert agent["formatted_output"] == []
first, tool, input_only, output_only = thread["spans"]
assert tool["attributes"]["gen_ai.tool.call.arguments"] == json.dumps({"city": "Bangalore"})
assert tool["attributes"]["gen_ai.tool.call.result"] == "sunny"
assert first["time_unix"] == int((now - timedelta(seconds=8)).timestamp() * 1000)
@@ -78,9 +74,10 @@ def test_thread_returns_gen_ai_spans_in_order(
assert first["formatted_input"] == [{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}]
assert first["formatted_output"] == [{"role": "assistant", "parts": [tool_call], "finish_reason": "tool_call"}]
assert first["formatter_warnings"] == []
assert tool["formatter"] == "tool"
assert tool["formatted_input"] == [{"role": "assistant", "parts": [tool_call]}]
assert tool["formatted_output"] == [{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "sunny"}]}]
# tool spans carry no messages until their converter lands
assert tool["formatter"] == ""
assert tool["formatted_input"] == []
assert tool["formatted_output"] == []
assert input_only["formatter"] == "openai.chat"
assert input_only["formatted_input"] == [{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "sunny"}]}]
assert input_only["formatted_output"] == []
@@ -105,7 +102,7 @@ def test_thread_paginates_with_cursors(
timestamps = [now - timedelta(seconds=6), now - timedelta(seconds=3), now - timedelta(seconds=3)]
insert_traces(
[
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-pages"}, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-pages"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
]
)
@@ -167,7 +164,7 @@ def test_thread_paginates_across_buckets(
span_ids = [TraceIdGenerator.span_id() for _ in timestamps]
insert_traces(
[
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-buckets"}, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-buckets"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
]
)
@@ -212,25 +209,16 @@ def test_thread_opens_around_span(
resources = {"service.name": "tracedetail-thread-anchor"}
root_id = TraceIdGenerator.span_id()
llm_ids = [TraceIdGenerator.span_id() for _ in range(5)]
http_id = TraceIdGenerator.span_id()
# http span, not a thread row, sits between the third and fourth llm spans
agent_id = TraceIdGenerator.span_id()
# agent span, not a thread row, sits between the third and fourth llm spans
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=19), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
*(
Traces(
timestamp=now - timedelta(seconds=18 - 3 * i),
trace_id=trace_id,
span_id=span_id,
parent_span_id=root_id,
name="chat gpt-4o",
resources=resources,
attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])},
attribute_write_mode="json_only",
)
Traces(timestamp=now - timedelta(seconds=18 - 3 * i), trace_id=trace_id, span_id=span_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
for i, span_id in enumerate(llm_ids)
),
Traces(timestamp=now - timedelta(seconds=11), trace_id=trace_id, span_id=http_id, parent_span_id=root_id, name="GET /weather", resources=resources, attributes={"http.request.method": "GET"}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=11), trace_id=trace_id, span_id=agent_id, parent_span_id=root_id, name="invoke_agent planner", resources=resources, attributes={"gen_ai.agent.name": "planner"}, attribute_write_mode="json_only"),
]
)
@@ -243,7 +231,7 @@ def test_thread_opens_around_span(
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
return response.json()["data"]
# thread span: included with its neighbours
# span with messages: included with its neighbours
around = get_page({"limit": 3, "spanId": llm_ids[2]})
assert [span["span_id"] for span in around["spans"]] == llm_ids[1:4]
assert around["prevCursor"]
@@ -251,11 +239,11 @@ def test_thread_opens_around_span(
assert [span["span_id"] for span in get_page({"limit": 3, "before": around["prevCursor"]})["spans"]] == llm_ids[:1]
assert [span["span_id"] for span in get_page({"limit": 3, "after": around["nextCursor"]})["spans"]] == llm_ids[4:]
# span outside the thread: only its neighbours
around_http = get_page({"limit": 2, "spanId": http_id})
assert [span["span_id"] for span in around_http["spans"]] == llm_ids[2:4]
assert around_http["prevCursor"]
assert around_http["nextCursor"]
# span without messages: only its neighbours
around_agent = get_page({"limit": 2, "spanId": agent_id})
assert [span["span_id"] for span in around_agent["spans"]] == llm_ids[2:4]
assert around_agent["prevCursor"]
assert around_agent["nextCursor"]
# near the start, the short side gives its room to the other
at_start = get_page({"limit": 3, "spanId": llm_ids[0]})
@@ -299,11 +287,11 @@ def test_thread_reads_spans_across_json_rollout(
legacy_id, json_id = TraceIdGenerator.span_id(), TraceIdGenerator.span_id()
insert_traces(
[
Traces(timestamp=rollout - timedelta(minutes=10), trace_id=before_trace_id, span_id=before_ids[0], name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": "first"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=8), trace_id=before_trace_id, span_id=TraceIdGenerator.span_id(), name="GET /weather", resources=resources, attributes={"http.request.method": "GET"}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=before_trace_id, span_id=before_ids[1], name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": json.dumps([{"role": "assistant", "content": "second"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=straddle_trace_id, span_id=legacy_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": "legacy"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout + timedelta(minutes=5), trace_id=straddle_trace_id, span_id=json_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": "json"}])}, attribute_write_mode="json_only"),
Traces(timestamp=rollout - timedelta(minutes=10), trace_id=before_trace_id, span_id=before_ids[0], name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "first"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=8), trace_id=before_trace_id, span_id=TraceIdGenerator.span_id(), name="invoke_agent planner", resources=resources, attributes={"gen_ai.agent.name": "planner"}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=before_trace_id, span_id=before_ids[1], name="chat gpt-4o", resources=resources, attributes={"gen_ai.output.messages": json.dumps([{"role": "assistant", "content": "second"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=straddle_trace_id, span_id=legacy_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "legacy"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout + timedelta(minutes=5), trace_id=straddle_trace_id, span_id=json_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "json"}])}, attribute_write_mode="json_only"),
]
)
@@ -334,7 +322,7 @@ def test_thread_reads_spans_across_json_rollout(
assert straddle_spans[1]["formatted_input"] == [{"role": "user", "parts": [{"type": "text", "content": "json"}]}]
def test_thread_without_gen_ai_spans_is_empty(
def test_thread_without_messages_is_empty(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
@@ -343,14 +331,7 @@ def test_thread_without_gen_ai_spans_is_empty(
) -> None:
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
trace_id = TraceIdGenerator.trace_id()
resources = {"service.name": "tracedetail-thread-empty"}
# messages without a gate key, as a failed call or an unmapped SDK writes them, are not a row either
insert_traces(
[
Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=5), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="GET /health", resources=resources, attribute_write_mode="json_only"),
Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=4), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="chat", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "hi"}])}, attribute_write_mode="json_only"),
]
)
insert_traces([Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=5), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="GET /health", resources={"service.name": "tracedetail-thread-empty"}, attribute_write_mode="json_only")])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread"), headers={"Authorization": f"Bearer {token}"}, timeout=10)