Compare commits

..

1 Commits

Author SHA1 Message Date
Abhi Kumar
53100381a5 refactor(query-builder): compose the panel-type field map instead of listing it
`panelTypeDataSourceFormValuesMap` spelled out all 21 panel-type x data-source
combinations as literal field lists, 435 lines of them. The combinations reduce to
seven distinct sets: logs and traces carry identical fields in every case, metrics
adds its two aggregation steps, and each panel type is one of four query shapes.
Most of the apparent variation was ordering noise — the sets for a bar chart and a
table on logs are equal, listed in a different order.

Composed from those rules it is 84 lines, and the policy is legible: charts, table
and pie share a surface, table and pie differ only by `reduceTo` on metrics, a
single value has nothing to group or order, and raw rows carry no aggregation. Two
asymmetries that were buried in the literals are called out where they are decided
rather than reproduced silently.

No behaviour change: the composition was checked cell by cell against the previous
table before it was removed. The specs pin the rules rather than the values, so they
fail when a rule changes — the moment to stop and decide — instead of whenever a
field moves. One of them states the hazard composing introduces: the aggregating
types share a field list, so an edit meant for charts reaches table and pie too.
Another pins one array per cell, because the QueryBuilder provider pushes onto the
list it reads from this map.

Assisted-by: Claude Opus 5
2026-09-07 14:22:22 +05:30
36 changed files with 309 additions and 1365 deletions

View File

@@ -8087,7 +8087,6 @@ components:
- TRACES_BASED_ALERT
- LOGS_BASED_ALERT
- EXCEPTIONS_BASED_ALERT
- AI_TRACES_BASED_ALERT
type: string
RuletypesBasicRuleThreshold:
properties:

View File

@@ -9241,7 +9241,6 @@ export enum RuletypesAlertTypeDTO {
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
}
export enum RuletypesMatchTypeDTO {
at_least_once = 'at_least_once',

View File

@@ -2,10 +2,8 @@ import { cloneDeep, isEmpty } from 'lodash-es';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import {
BuilderQuery,
DistributionData,
MetricRangePayloadV5,
QueryEnvelope,
QueryRangeRequestV5,
RawData,
ScalarData,
@@ -13,11 +11,6 @@ import {
} from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
const isBuilderQueryEnvelope = (
envelope: QueryEnvelope,
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
@@ -416,19 +409,21 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
(acc, query) => {
if (
isBuilderQueryEnvelope(query) &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
params?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {
if (
query.type === 'builder_query' &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).

View File

@@ -14,7 +14,6 @@ import {
QueryBuilderFormula as V5QueryBuilderFormula,
QueryEnvelope,
QueryRangePayloadV5,
RequestType,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -936,41 +935,3 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
});
});
});
describe('convertBuilderQueriesToV5 builder query type', () => {
const buildEnvelope = (
builderQueryType: IBuilderQuery['builderQueryType'],
requestType: RequestType,
): QueryEnvelope => {
const [envelope] = convertBuilderQueriesToV5(
{
A: {
dataSource: DataSource.TRACES,
queryName: 'A',
builderQueryType,
} as unknown as IBuilderQuery,
},
requestType,
);
return envelope;
};
it.each<[RequestType]>([
['trace'],
['raw'],
['time_series'],
['scalar'],
['distribution'],
])('sends builder_ai_query for the %s request type', (requestType) => {
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
'builder_ai_query',
);
});
it.each<[string, IBuilderQuery['builderQueryType']]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('sends builder_query for %s', (_label, builderQueryType) => {
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
});
});

View File

@@ -365,7 +365,7 @@ export function convertBuilderQueriesToV5(
}
return {
type: queryData.builderQueryType ?? 'builder_query',
type: 'builder_query' as QueryType,
spec,
};
},

View File

@@ -16,6 +16,8 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
@@ -52,12 +54,6 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './fieldSuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -265,8 +261,10 @@ function QuerySearch({
const dashboardDynamicVariables = useDynamicVariableSuggestions();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
const generateOptions = (keys: {
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -319,9 +317,8 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
const response = await getKeySuggestions({
signal: dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
@@ -363,7 +360,6 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
queryData.builderQueryType,
],
);
@@ -497,11 +493,10 @@ function QuerySearch({
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await fetchFieldValuesForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
: await getValueSuggestions({
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
@@ -606,7 +601,6 @@ function QuerySearch({
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
queryData.builderQueryType,
],
);

View File

@@ -1,215 +0,0 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { DataSource } from 'types/common/queryBuilder';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
} from '../fieldSuggestions';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsKeys: jest.fn(),
getAIObservabilityFieldsValues: jest.fn(),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn(),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn(),
}));
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
typeof getAIObservabilityFieldsKeys
>;
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
typeof getAIObservabilityFieldsValues
>;
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
>;
const aiValuesResponse = (
values: { stringValues?: string[]; numberValues?: number[] } | null,
complete = true,
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
({
status: 'success',
data: { complete, values },
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
describe('fetchFieldKeysForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: {
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
},
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const keys = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: 'llm',
});
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
expect(mockedGenericKeys).not.toHaveBeenCalled();
expect(keys.data.data).toStrictEqual({
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
mockedGenericKeys.mockResolvedValue({
data: { status: 'success', data: { complete: true, keys: {} } },
} as Awaited<ReturnType<typeof getKeySuggestions>>);
await fetchFieldKeysForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
searchText: 'svc',
});
expect(mockedAIKeys).not.toHaveBeenCalled();
expect(mockedGenericKeys).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
);
});
it('normalizes a null ai_observability keys payload to an empty map', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: { complete: false, keys: null },
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const response = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: '',
});
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
});
it('passes the generic response through untouched', async () => {
const genericResponse = {
data: { status: 'success', data: { complete: true, keys: {} } },
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
mockedGenericKeys.mockResolvedValue(genericResponse);
await expect(
fetchFieldKeysForQuery({
builderQueryType: 'builder_query',
dataSource: DataSource.TRACES,
searchText: '',
}),
).resolves.toBe(genericResponse);
});
});
describe('fetchFieldValuesForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIValues.mockResolvedValue(
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
);
const response = await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'gen_ai.request.model',
searchText: 'gpt',
});
expect(mockedGenericValues).not.toHaveBeenCalled();
expect(response).toStrictEqual({
data: {
data: {
complete: true,
values: { stringValues: ['gpt-4o'], numberValues: [] },
},
},
});
});
it('forwards the key as the name the endpoint expects', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'total_tokens',
searchText: '',
});
expect(mockedAIValues).toHaveBeenCalledWith({
name: 'total_tokens',
searchText: '',
});
});
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
await expect(
fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'llm_call_count',
searchText: '',
}),
).resolves.toStrictEqual({
data: { data: { complete: false, values: null } },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
const genericResponse = {
data: {
data: { complete: false, values: { stringValues: ['frontend'] } },
},
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
mockedGenericValues.mockResolvedValue(genericResponse);
const response = await fetchFieldValuesForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
});
expect(mockedAIValues).not.toHaveBeenCalled();
expect(mockedGenericValues).toHaveBeenCalledWith(
expect.objectContaining({
signal: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
}),
);
expect(response).toBe(genericResponse);
});
});

View File

@@ -1,111 +0,0 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export interface SuggestedFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
export interface SuggestedFieldKeysPayload {
complete: boolean;
keys: SuggestedFieldKeysByName;
}
export interface SuggestedFieldKeysResponse {
data: { data?: SuggestedFieldKeysPayload };
}
export interface SuggestedFieldValuesPayload {
complete?: boolean;
values?: {
stringValues?: string[] | null;
numberValues?: number[] | null;
} | null;
}
export interface SuggestedFieldValuesResponse {
data: { data?: SuggestedFieldValuesPayload };
}
interface FetchFieldKeysParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
metricNamespace?: string;
}
interface FetchFieldValuesParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
key: string;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
}
export const fetchFieldKeysForQuery = async ({
builderQueryType,
dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsKeys({ searchText });
return {
data: {
data: response.data
? { complete: response.data.complete, keys: response.data.keys ?? {} }
: undefined,
},
};
}
return getKeySuggestions({
signal: dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
});
};
export const fetchFieldValuesForQuery = async ({
builderQueryType,
dataSource,
key,
searchText,
metricName,
signalSource,
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsValues({
name: key,
searchText,
});
return { data: { data: response.data } };
}
// getValueSuggestions' declared response type does not match what the endpoint returns.
return getValueSuggestions({
signal: dataSource,
key,
searchText,
signalSource,
metricName,
}) as unknown as Promise<SuggestedFieldValuesResponse>;
};

View File

@@ -54,7 +54,7 @@ export const QueryV2 = forwardRef(function QueryV2(
const { cloneQuery, panelType } = useQueryBuilder();
const showFunctions = query?.functions?.length > 0;
const { dataSource, builderQueryType } = query;
const { dataSource } = query;
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -94,9 +94,8 @@ export const QueryV2 = forwardRef(function QueryV2(
);
const showSpanScopeSelector = useMemo(
() =>
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
[dataSource, builderQueryType],
() => dataSource === DataSource.TRACES,
[dataSource],
);
const showInlineQuerySearch = useMemo(() => {

View File

@@ -348,19 +348,6 @@ export const initialQueryMeterWithType: Query = {
},
};
export const initialQueryAIWithType: Query = {
...initialQueryWithType,
builder: {
...initialQueryWithType.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
builderQueryType: 'builder_ai_query',
},
],
},
};
export const operatorsByTypes: Record<LocalDataType, string[]> = {
string: Object.values(StringOperators),
number: Object.values(NumberOperators),

View File

@@ -11,13 +11,17 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import {
getExportQueryData,
getQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
@@ -48,7 +52,6 @@ import {
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
@@ -115,7 +118,7 @@ function Explorer(): JSX.Element {
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueryAIWithType,
initialQueriesMap.traces,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
@@ -182,7 +185,7 @@ function Explorer(): JSX.Element {
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueryAIWithType,
stagedQuery || initialQueriesMap.traces,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],

View File

@@ -17,11 +17,12 @@ import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import {
getTraceLink,
transformSpanRows,
@@ -42,7 +43,6 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
@@ -94,7 +94,7 @@ function ListView({
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);

View File

@@ -1,6 +1,8 @@
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
@@ -8,16 +10,33 @@ import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: false, isDisabled: true },
having: { isHidden: false, isDisabled: true },
}),
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
@@ -26,10 +45,14 @@ function QuerySection(): JSX.Element {
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={isListViewPanel}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);

View File

@@ -14,9 +14,10 @@ import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
@@ -30,7 +31,6 @@ import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { getListViewQuery } from '../explorerUtils';
import { columns, PER_PAGE_OPTIONS } from './configs';
import styles from './TracesView.module.scss';
@@ -60,7 +60,7 @@ function TracesView({
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
[stagedQuery],
);

View File

@@ -1,61 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
stagedQuery: Query,
orderBy?: string,
): Query => {
const query = stagedQuery
? cloneDeep(stagedQuery)
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {
const queryData = query.builder.queryData[i];
queryData.groupBy = [];
queryData.having = {
expression: '',
};
queryData.orderBy = orderByPayload;
}
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -0,0 +1,119 @@
import {
panelTypeDataSourceFormValuesMap,
type PartialPanelTypes,
} from 'lib/query/panelQuery';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
/**
* The map is composed from a few shape rules rather than spelled out per panel type
* and data source. These specs pin the rules themselves — each one fails only when a
* rule changes, which is the moment to stop and decide, rather than whenever any
* field moves.
*
* The composition it replaced was checked cell by cell against the previous literal
* table, which is in git history at `main:frontend/src/lib/query/panelQuery.ts`.
*/
function fieldsFor(
panelType: keyof PartialPanelTypes,
dataSource: DataSource,
): string[] {
return panelTypeDataSourceFormValuesMap[panelType][dataSource].builder
.queryData;
}
/** Fields present in `to` but not in `from`. */
function added(from: string[], to: string[]): string[] {
return to.filter((field) => !from.includes(field)).sort();
}
/** Panel types built on the aggregating field list. */
const AGGREGATING_TYPES: (keyof PartialPanelTypes)[] = [
PANEL_TYPES.BAR,
PANEL_TYPES.HISTOGRAM,
PANEL_TYPES.TABLE,
PANEL_TYPES.PIE,
];
/** Panel types that reduce each series to one cell or slice. */
const SCALAR_TYPES: (keyof PartialPanelTypes)[] = [
PANEL_TYPES.TABLE,
PANEL_TYPES.PIE,
];
describe('panelTypeDataSourceFormValuesMap', () => {
const seriesLogs = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
const seriesMetrics = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.METRICS);
it('shares one builder surface between logs and traces', () => {
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
expect(sources[DataSource.LOGS].builder.queryData).toStrictEqual(
sources[DataSource.TRACES].builder.queryData,
);
});
});
// The provider pushes onto the list it reads from this map, so two cells backed by
// one instance would leak fields into each other.
it('gives every cell its own array instance', () => {
const arrays = Object.values(panelTypeDataSourceFormValuesMap).flatMap(
(sources) =>
Object.values(sources).map((source) => source.builder.queryData),
);
expect(new Set(arrays).size).toBe(arrays.length);
});
// One consequence of composing: the aggregating types share a single field list, so
// an edit meant for charts reaches table and pie too.
it.each(AGGREGATING_TYPES)(
'gives %s the same non-metrics fields as a time series',
(panelType) => {
expect(fieldsFor(panelType, DataSource.LOGS)).toStrictEqual(seriesLogs);
},
);
it('adds both metrics aggregation steps for metrics', () => {
expect(added(seriesLogs, seriesMetrics)).toStrictEqual([
'spaceAggregation',
'timeAggregation',
]);
});
it.each(SCALAR_TYPES)('offers reduceTo to %s on metrics only', (panelType) => {
expect(
added(seriesMetrics, fieldsFor(panelType, DataSource.METRICS)),
).toStrictEqual(['reduceTo']);
expect(fieldsFor(panelType, DataSource.LOGS)).not.toContain('reduceTo');
});
it('drops grouping, paging and ordering for a single value', () => {
const value = fieldsFor(PANEL_TYPES.VALUE, DataSource.LOGS);
expect(added(value, seriesLogs)).toStrictEqual([
'groupBy',
'limit',
'orderBy',
]);
expect(value).toContain('reduceTo');
});
it('offers no aggregation fields to raw rows', () => {
const rows = fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS);
expect(rows).not.toContain('aggregateAttribute');
expect(rows).not.toContain('aggregateOperator');
expect(rows).not.toContain('groupBy');
expect(rows).not.toContain('having');
expect(rows).not.toContain('stepInterval');
});
it('drops paging and ordering for metrics rows', () => {
expect(
added(
fieldsFor(PANEL_TYPES.LIST, DataSource.METRICS),
fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS),
),
).toStrictEqual(['functions', 'limit', 'orderBy']);
});
});

View File

@@ -101,441 +101,99 @@ export type PartialPanelTypes = {
[PANEL_TYPES.HISTOGRAM]: 'histogram';
};
/**
* Builder fields carried across a panel-type switch, per panel type and data source.
*
* The 21 combinations reduce to a handful of rules, so they are composed rather than
* spelled out: logs and traces carry the same fields in every case, metrics splits its
* aggregation in two, and each panel type is one of four query shapes. Order is
* irrelevant — `handleQueryChange` copies each field independently.
*
* `panelTypeFormValues` in `__tests__/__fixtures__` pins the previous literal table so
* the composition can be shown to reproduce it exactly.
*/
/** Every field an aggregating query carries — shared by charts, table and pie. */
const AGGREGATING_FIELDS = [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
] as const;
/** Metrics aggregates over time and then over space, so it carries both steps. */
const METRICS_AGGREGATION = ['timeAggregation', 'spaceAggregation'] as const;
function omit(fields: readonly string[], ...omitted: string[]): string[] {
return fields.filter((field) => !omitted.includes(field));
}
const SERIES = [...AGGREGATING_FIELDS];
const SERIES_METRICS = [...SERIES, ...METRICS_AGGREGATION];
// Table and pie reduce each series to a single cell/slice. Note the asymmetry, carried
// over from the previous table: `reduceTo` is offered for metrics only.
const SCALAR_METRICS = [...SERIES_METRICS, 'reduceTo'];
/** A single value has no series to group, limit or order. */
const SINGLE_VALUE = [
...omit(AGGREGATING_FIELDS, 'groupBy', 'limit', 'orderBy'),
'reduceTo',
];
const SINGLE_VALUE_METRICS = [...SINGLE_VALUE, ...METRICS_AGGREGATION];
/** Raw rows carry no aggregation at all. */
const RAW_ROWS = [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
];
// Metrics rows drop paging and ordering too, as before.
const RAW_ROWS_METRICS = ['queryName', 'filters', 'filter', 'aggregations'];
/**
* Logs and traces share a builder surface; metrics is the one that differs.
*
* Each cell gets its own copy. `QueryBuilder`'s provider pushes onto the list it reads
* from this map, so cells sharing one array instance would contaminate each other.
*/
function bySource(
logsAndTraces: readonly string[],
metrics: readonly string[],
): Record<DataSource, any> {
return {
[DataSource.LOGS]: { builder: { queryData: [...logsAndTraces] } },
[DataSource.TRACES]: { builder: { queryData: [...logsAndTraces] } },
[DataSource.METRICS]: { builder: { queryData: [...metrics] } },
};
}
export const panelTypeDataSourceFormValuesMap: Record<
keyof PartialPanelTypes,
Record<DataSource, any>
> = {
[PANEL_TYPES.BAR]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TIME_SERIES]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.HISTOGRAM]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TABLE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'reduceTo',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.PIE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'reduceTo',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.LIST]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: ['queryName', 'filters', 'filter', 'aggregations'],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
],
},
},
},
[PANEL_TYPES.VALUE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'reduceTo',
'having',
'functions',
'stepInterval',
'queryName',
'expression',
'disabled',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'having',
'reduceTo',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'reduceTo',
'having',
'functions',
'stepInterval',
'queryName',
'expression',
'disabled',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.TIME_SERIES]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.BAR]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};
export function handleQueryChange(

View File

@@ -475,7 +475,6 @@ export function QueryBuilderProvider({
const newQuery: IBuilderQuery = {
...initialBuilderQuery,
source: queries?.[0]?.source || '',
builderQueryType: queries?.[0]?.builderQueryType,
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
expression: createNewBuilderItemName({
existNames,

View File

@@ -1,55 +0,0 @@
import {
initialQueriesMap,
initialQueryAIWithType,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const renderQueryBuilder = (
initialQuery: Query,
): ReturnType<
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
> => {
const hook = renderHook(() => useQueryBuilder(), {
wrapper: AllTheProviders,
});
act(() => {
hook.result.current.initQueryBuilderData(initialQuery);
});
return hook;
};
describe('createNewBuilderQuery builderQueryType propagation', () => {
it('carries builderQueryType from the first query onto an added query', () => {
const { result } = renderQueryBuilder(initialQueryAIWithType);
expect(
result.current.currentQuery.builder.queryData[0].builderQueryType,
).toBe('builder_ai_query');
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
expect(
result.current.currentQuery.builder.queryData[1].builderQueryType,
).toBe('builder_ai_query');
});
it('leaves builderQueryType unset when the first query has none', () => {
const { result } = renderQueryBuilder(initialQueriesMap.traces);
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
expect(
result.current.currentQuery.builder.queryData[1].builderQueryType,
).toBeUndefined();
});
});

View File

@@ -8,7 +8,6 @@ import {
} from 'types/common/queryBuilder';
import {
BuilderQueryType,
Filter,
Having as HavingV5,
LogAggregation,
@@ -91,7 +90,6 @@ export type IBuilderQuery = {
offset?: number;
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
source?: 'meter' | '';
builderQueryType?: BuilderQueryType;
};
export interface IClickHouseQuery {

View File

@@ -16,7 +16,6 @@ export type RequestType =
export type QueryType =
| 'builder_query'
| 'builder_ai_query'
| 'builder_trace_operator'
| 'builder_formula'
| 'builder_sub_query'
@@ -24,11 +23,6 @@ export type QueryType =
| 'clickhouse_sql'
| 'promql';
export type BuilderQueryType = Extract<
QueryType,
'builder_query' | 'builder_ai_query'
>;
export type OrderDirection = 'asc' | 'desc';
export type JoinType = 'inner' | 'left' | 'right' | 'full' | 'cross';

View File

@@ -12,32 +12,25 @@ import (
// PrepareParamsForTracesV5 returns the traces explorer query params for the
// given range and filter; the traces explorer writes its time params in
// nanoseconds. queryType is builder_ai_query for the AI observability explorer.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string, queryType qbtypes.QueryType) url.Values {
return prepareExplorerParams("traces", queryType, start.UnixNano(), end.UnixNano(), whereClause)
// nanoseconds.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("traces", start.UnixNano(), end.UnixNano(), whereClause)
}
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
// range and filter; the logs explorer writes its time params in milliseconds.
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
return prepareExplorerParams("logs", start.UnixMilli(), end.UnixMilli(), whereClause)
}
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
// builder_query is the explorer default, so it is left out to keep existing links unchanged
builderQueryType := ""
if queryType != qbtypes.QueryTypeBuilder {
builderQueryType = queryType.StringValue()
}
func prepareExplorerParams(dataSource string, start, end int64, whereClause string) url.Values {
urlData := URLShareableCompositeQuery{
QueryType: "builder",
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{{
DataSource: dataSource,
BuilderQueryType: builderQueryType,
Filter: &FilterExpression{Expression: whereClause},
DataSource: dataSource,
Filter: &FilterExpression{Expression: whereClause},
}},
QueryFormulas: make([]string, 0),
},
@@ -54,8 +47,7 @@ func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start
// BuilderQueryForSignal returns the filter expression and group-by keys of the
// builder query for the given signal, or found=false when the composite query
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
// queries (builder_ai_query) count as trace builder queries.
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts).
// TODO(srikanthccv): re-visit this and support multiple queries.
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
switch signal {
@@ -71,7 +63,7 @@ func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal teleme
var q qbtypes.QueryBuilderQuery[T]
found := false
for _, query := range queries {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
if query.Type != qbtypes.QueryTypeBuilder {
continue
}
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {

View File

@@ -30,15 +30,6 @@ func TestBuilderQueryForSignal(t *testing.T) {
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{Name: "C"},
}
aiTraceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "D",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
},
}
t.Run("logs query among mixed queries", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
@@ -55,14 +46,6 @@ func TestBuilderQueryForSignal(t *testing.T) {
assert.Empty(t, groupBy)
})
t.Run("ai trace query counts as traces", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "session.id", groupBy[0].Name)
})
t.Run("no builder query for signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)

View File

@@ -13,9 +13,8 @@ type FilterExpression struct {
// LinkQuery carries the only fields the explorer pages read from a shared
// link; the frontend fills in the rest of the query shape with defaults.
type LinkQuery struct {
DataSource string `json:"dataSource"`
BuilderQueryType string `json:"builderQueryType,omitempty"`
Filter *FilterExpression `json:"filter,omitempty"`
DataSource string `json:"dataSource"`
Filter *FilterExpression `json:"filter,omitempty"`
}
type URLShareableBuilderQuery struct {

View File

@@ -41,8 +41,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
return nil
}
signal, ok := relatedLinkSignal(rule.AlertType)
if !ok {
if rule.AlertType != ruletypes.AlertTypeLogs && rule.AlertType != ruletypes.AlertTypeTraces {
return nil
}
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
@@ -63,6 +62,10 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
}
signal := telemetrytypes.SignalLogs
if rule.AlertType == ruletypes.AlertTypeTraces {
signal = telemetrytypes.SignalTraces
}
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
@@ -94,20 +97,8 @@ func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, st
switch b.alertType {
case ruletypes.AlertTypeLogs:
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode(), ""
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
return "", contextlinks.PrepareParamsForTracesV5(start, end, whereClause, b.alertType.BuilderQueryType()).Encode()
case ruletypes.AlertTypeTraces:
return "", contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
}
return "", ""
}
// relatedLinkSignal returns the explorer signal that related links open for
// the alert type, or ok=false when the alert type has none (e.g. metrics).
func relatedLinkSignal(alertType ruletypes.AlertType) (telemetrytypes.Signal, bool) {
switch alertType {
case ruletypes.AlertTypeLogs:
return telemetrytypes.SignalLogs, true
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
return telemetrytypes.SignalTraces, true
}
return telemetrytypes.SignalUnspecified, false
}

View File

@@ -854,12 +854,12 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
} else if rule.AlertType == ruletypes.AlertTypeTraces || rule.AlertType == ruletypes.AlertTypeAITraces {
} else if rule.AlertType == ruletypes.AlertTypeTraces {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder || query.Type == qbtypes.QueryTypeBuilderAI {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
@@ -873,7 +873,7 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause, rule.AlertType.BuilderQueryType()).Encode()
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
}
}
}

View File

@@ -420,7 +420,7 @@ func (r *BaseRule) ShouldSkipNewGroups() bool {
func (r *BaseRule) isFilterNewSeriesSupported() bool {
if r.ruleCondition.CompositeQuery.QueryType == ruletypes.QueryTypeBuilder {
for _, query := range r.ruleCondition.CompositeQuery.Queries {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
if query.Type != qbtypes.QueryTypeBuilder {
continue
}
switch query.Spec.(type) {

View File

@@ -7,7 +7,6 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -165,24 +164,6 @@ type filterNewSeriesTestCase struct {
expectError bool
}
func TestBaseRule_IsFilterNewSeriesSupported(t *testing.T) {
postableRule := createPostableRule(&ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "max(trace.total_tokens)"}},
},
}},
})
rule, err := NewBaseRule("test-rule", valuer.GenerateUUID(), &postableRule, mustParseURL(t, "http://localhost:8080"), WithLogger(instrumentationtest.New().Logger()))
require.NoError(t, err)
assert.False(t, rule.isFilterNewSeriesSupported())
}
func TestBaseRule_FilterNewSeries(t *testing.T) {
defaultEvalTime := time.Unix(1700000000, 0)
defaultNewGroupEvalDelay := valuer.MustParseTextDuration("2m")

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/metricsstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/tracesstatementbuilder"
@@ -128,40 +127,3 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
0, // maxConcurrentQueries (0 means default)
)
}
func prepareQuerierForAITraces(t *testing.T, telemetryStore telemetrystore.TelemetryStore, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) querier.Querier {
t.Helper()
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
for _, keys := range keysMap {
for _, key := range keys {
key.Signal = telemetrytypes.SignalTraces
}
}
metadataStore.KeysMap = keysMap
fl := flaggertest.New(t)
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(context.Background(), providerSettings, statementbuilder.Config{})
require.NoError(t, err)
return querier.New(
providerSettings,
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
aiTraceStmtBuilder,
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
0,
0, // maxConcurrentQueries (0 means default)
)
}

View File

@@ -132,7 +132,7 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause)
}
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
@@ -308,14 +308,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
// is used alert grouping, and we want to group alerts with the same
// label set, but different timestamps, together.
switch r.typ {
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
case ruletypes.AlertTypeTraces:
params := r.prepareParamsForTraces(ctx, ts, smpl.Metric)
if len(params) > 0 {
explorerPath := "traces-explorer"
if r.typ == ruletypes.AlertTypeAITraces {
explorerPath = "ai-observability/explorer"
}
link := r.ExternalURL(explorerPath, params)
link := r.ExternalURL("traces-explorer", params)
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedTraces, Value: link})
}

View File

@@ -916,104 +916,6 @@ func TestThresholdRuleTracesLink(t *testing.T) {
}
}
func TestThresholdRuleAITracesLink(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "AI traces link test",
AlertType: ruletypes.AlertTypeAITraces,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: valuer.MustParseTextDuration("5m"),
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
StepInterval: qbtypes.Step{Duration: time.Minute},
Aggregations: []qbtypes.TraceAggregation{{
Expression: "count()",
}},
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{
Expression: "service.name = 'llm-gateway'",
},
},
}},
},
},
}
cols := make([]cmock.ColumnType, 0)
cols = append(cols, cmock.ColumnType{Name: "value", Type: "Float64"})
cols = append(cols, cmock.ColumnType{Name: "attr", Type: "String"})
cols = append(cols, cmock.ColumnType{Name: "timestamp", Type: "DateTime"})
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{
"service.name": {
{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
logger := instrumentationtest.New().Logger()
for idx, c := range testCases {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
rows := cmock.NewRows(cols, c.values)
telemetryStore.Mock().
ExpectQuery("SELECT any").
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
TargetValue: &c.target,
TargetUnit: c.targetUnit,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
},
},
}
postableRule.Annotations = map[string]string{
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
require.NoError(t, err, "case %d", idx)
alertsFound, err := rule.Eval(context.Background(), time.Now())
require.NoError(t, err, "case %d", idx)
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
for _, item := range rule.Active {
link := item.Annotations.Map()[ruletypes.AnnotationRelatedTraces]
assert.True(t, strings.HasPrefix(link, "http://localhost:8080/ai-observability/explorer?"), "case %d: %s", idx, link)
assert.Contains(t, link, "builder_ai_query", "case %d", idx)
assert.Contains(t, link, "llm-gateway", "case %d", idx)
}
}
}
func TestThresholdRuleLogsLink(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Logs link test",

View File

@@ -24,7 +24,6 @@ const (
AlertTypeTraces AlertType = "TRACES_BASED_ALERT"
AlertTypeLogs AlertType = "LOGS_BASED_ALERT"
AlertTypeExceptions AlertType = "EXCEPTIONS_BASED_ALERT"
AlertTypeAITraces AlertType = "AI_TRACES_BASED_ALERT"
)
// Enum implements jsonschema.Enum; returns the acceptable values for AlertType.
@@ -34,19 +33,9 @@ func (AlertType) Enum() []any {
AlertTypeTraces,
AlertTypeLogs,
AlertTypeExceptions,
AlertTypeAITraces,
}
}
// BuilderQueryType returns the query type the alert type's builder queries
// carry; only AI trace alerts use builder_ai_query.
func (t AlertType) BuilderQueryType() qbtypes.QueryType {
if t == AlertTypeAITraces {
return qbtypes.QueryTypeBuilderAI
}
return qbtypes.QueryTypeBuilder
}
const (
DefaultSchemaVersion = "v1"
SchemaVersionV2Alpha1 = "v2alpha1"
@@ -417,11 +406,11 @@ func (r *PostableRule) Validate() error {
if r.AlertType != "" {
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions:
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"alertType: unsupported value %q; must be one of %q, %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces))
"alertType: unsupported value %q; must be one of %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions))
}
}

View File

@@ -209,10 +209,6 @@ func TestValidate_PostableRule_Common(t *testing.T) {
name: "valid alertType EXCEPTIONS_BASED_ALERT",
json: patchJSON(validV1Builder(), `{"alertType": "EXCEPTIONS_BASED_ALERT"}`),
},
{
name: "valid alertType AI_TRACES_BASED_ALERT",
json: patchJSON(validV1Builder(), `{"alertType": "AI_TRACES_BASED_ALERT"}`),
},
{
name: "empty alertType is ok (optional)",
json: removeField(validV1Builder(), "alertType"),

View File

@@ -1,16 +0,0 @@
{"timestamp": "2026-01-29T10:00:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f601", "span_id": "c1b2c3d4e5f6a701", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:00:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f601", "span_id": "d1b2c3d4e5f6a701", "parent_span_id": "c1b2c3d4e5f6a701", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 300, "gen_ai.usage.output_tokens": 120, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:00:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f602", "span_id": "c1b2c3d4e5f6a702", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:00:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f602", "span_id": "d1b2c3d4e5f6a702", "parent_span_id": "c1b2c3d4e5f6a702", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 310, "gen_ai.usage.output_tokens": 125, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:01:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f603", "span_id": "c1b2c3d4e5f6a703", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:01:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f603", "span_id": "d1b2c3d4e5f6a703", "parent_span_id": "c1b2c3d4e5f6a703", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 320, "gen_ai.usage.output_tokens": 130, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:01:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f604", "span_id": "c1b2c3d4e5f6a704", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:01:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f604", "span_id": "d1b2c3d4e5f6a704", "parent_span_id": "c1b2c3d4e5f6a704", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 330, "gen_ai.usage.output_tokens": 135, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:02:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f605", "span_id": "c1b2c3d4e5f6a705", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:02:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f605", "span_id": "d1b2c3d4e5f6a705", "parent_span_id": "c1b2c3d4e5f6a705", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 340, "gen_ai.usage.output_tokens": 140, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:02:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f606", "span_id": "c1b2c3d4e5f6a706", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:02:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f606", "span_id": "d1b2c3d4e5f6a706", "parent_span_id": "c1b2c3d4e5f6a706", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 350, "gen_ai.usage.output_tokens": 145, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:03:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f607", "span_id": "c1b2c3d4e5f6a707", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:03:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f607", "span_id": "d1b2c3d4e5f6a707", "parent_span_id": "c1b2c3d4e5f6a707", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 360, "gen_ai.usage.output_tokens": 150, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:03:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f608", "span_id": "c1b2c3d4e5f6a708", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:03:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f608", "span_id": "d1b2c3d4e5f6a708", "parent_span_id": "c1b2c3d4e5f6a708", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 370, "gen_ai.usage.output_tokens": 155, "_signoz.gen_ai.total_cost": 0.02}}

View File

@@ -1,73 +0,0 @@
{
"alert": "rule_state_history_ai_traces",
"ruleType": "threshold_rule",
"alertType": "AI_TRACES_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 0,
"matchType": "at_least_once",
"op": "above",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_ai_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {
"expression": "trace.input_tokens > 100"
},
"groupBy": [
{
"name": "service.name",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"aggregations": [
{
"expression": "max(trace.total_tokens)"
}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -101,47 +101,3 @@ def test_traces_rule_history_related_links(
assert contributor_link["start"] == query_start_ms * 1_000_000
assert contributor_link["end"] == query_end_ms * 1_000_000
assert_related_link_query(contributor_link, "traces", ["http.request.path", "/order", "service.name", "order-service"])
def test_ai_traces_rule_history_related_links(
signoz: types.SigNoz,
create_alert_rule_with_channel: Callable[[str], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query_start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=30)).timestamp() * 1000)
insert_alert_data(
[types.AlertData(type="traces", data_path="alerts/test_scenarios/rule_state_history_ai_traces/alert_data.jsonl")],
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
)
rule_id = create_alert_rule_with_channel("alerts/test_scenarios/rule_state_history_ai_traces/rule.json")
(item, query_end_ms) = wait_for_firing_timeline_entry(signoz, token, rule_id, query_start_ms)
assert labels_to_map(item["labels"]).get("service.name") == "llm-gateway"
assert item.get("relatedLogsLink", "") == ""
assert item.get("relatedTracesLink", "") != ""
# AI alert links follow the traces explorer shape: a nanosecond range
# anchored to the second-truncated entry timestamp
link = parse_related_link(item["relatedTracesLink"])
assert link["end"] == (item["unixMilli"] // 1000) * 1_000_000_000
assert link["end"] - link["start"] == RELATED_LINK_WINDOW_SECONDS * 1_000_000_000
assert_related_link_query(link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
# the AI explorer only resolves trace.* fields for builder_ai_query
assert link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "llm-gateway"]
assert len(contributors) == 1
assert contributors[0]["count"] >= 1
assert contributors[0].get("relatedLogsLink", "") == ""
assert contributors[0].get("relatedTracesLink", "") != ""
contributor_link = parse_related_link(contributors[0]["relatedTracesLink"])
assert contributor_link["start"] == query_start_ms * 1_000_000
assert contributor_link["end"] == query_end_ms * 1_000_000
assert_related_link_query(contributor_link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
assert contributor_link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"