mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 06:30:40 +01:00
Compare commits
1 Commits
feat/ai-tr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375b3f8305 |
@@ -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.
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' | ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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[],
|
||||
|
||||
@@ -5,5 +5,4 @@ export type GroupByFilterProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (values: BaseAutocompleteData[]) => void;
|
||||
disabled: boolean;
|
||||
signalSource?: string;
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: `[]`,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: `[]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user