Compare commits

...

9 Commits

Author SHA1 Message Date
Gaurav Tewari
75e6ceb4bb Merge remote-tracking branch 'refs/remotes/origin/main' into feat/ai-query-dashboard-editor 2026-09-01 16:58:23 +05:30
Gaurav Tewari
7d3273c423 feat(dashboards): support AI queries in the V2 panel editor
Adds an "AI Query Builder" tab to the V2 panel editor and makes
builder_ai_query survive a save/load round trip.

The tab is not an EQueryType: AI-ness stays on the per-query
builderQueryType tag, so the wire query type remains `builder` and the
existing serialization paths are untouched.

Assisted-by: Claude Opus 4.5
2026-09-01 12:42:00 +05:30
Gaurav Tewari
a88cc79ef9 chore: remove extra comments 2026-08-27 17:33:36 +05:30
Gaurav Tewari
e424082835 Merge remote-tracking branch 'origin/main' into feat/qb-changes-for-ai-explorer 2026-08-27 15:01:58 +05:30
Gaurav Tewari
2afed07b5b Merge remote-tracking branch 'refs/remotes/origin/main' into feat/qb-changes-for-ai-explorer 2026-08-27 12:31:02 +05:30
Gaurav Tewari
1c0dc018e0 chore: remove feildcontext 2026-08-27 08:58:28 +05:30
Gaurav Tewari
bb2511ce53 chore: update feild value changes 2026-08-27 08:39:35 +05:30
Gaurav Tewari
33d22c8b59 fix: keep the shared key-suggestion call site on the response envelope
fetchFieldKeysForQuery returned the unwrapped keys map, so QuerySearch guarded
on `keys` instead of `response.data.data`. That silently changed behaviour for
every explorer: a response carrying an envelope with no keys used to throw, and
had started skipping instead.

The fetcher now returns the response envelope, so the call site in the shared
component is unchanged from before apart from the fetcher swap. The generic
branch passes its response straight through; only the ai_observability branch
adapts, normalizing its nullable keys map.
2026-08-27 00:05:24 +05:30
Gaurav Tewari
c1b9de0c8a feat(llm-observability): wire the ai explorer to builder_ai_query
Adds the builder_ai_query envelope type, which the backend routes to the
gen_ai-scoped trace builder. Queries without the field still serialize as
builder_query, so nothing outside the AI explorer changes.

- filter bar keys and values come from the ai_observability endpoints; the
  per-trace aggregates are computed in SQL and never reach the metadata
  store, so the generic endpoints cannot serve them
- values forward fieldContext, which is what lets the endpoint reach those
  aggregates instead of scanning for attributes that were never ingested
- the span scope selector is hidden for AI queries: root and entrypoint
  spans AND badly with the gate, since GenAI attributes sit on nested spans
- newly added builder queries inherit builderQueryType from the first, the
  way they already inherit source
- response conversion reads aggregation metadata from both builder envelopes
- Trace replaces List as the explorer's default view, and the builder no
  longer renders its own order by for the list and trace panels

Assisted-by: Claude Opus 4.5
2026-08-26 20:37:00 +05:30
35 changed files with 1007 additions and 105 deletions

View File

@@ -2,8 +2,10 @@ 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,
@@ -11,6 +13,11 @@ 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>,
@@ -409,21 +416,15 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
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>,
) || {};
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
(acc, query) => {
if ('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,6 +14,7 @@ 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';
@@ -935,3 +936,41 @@ 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

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

View File

@@ -16,8 +16,6 @@ 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,
@@ -54,6 +52,12 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './fieldSuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -264,10 +268,8 @@ function QuerySearch({
);
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -320,8 +322,9 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await getKeySuggestions({
signal: dataSource,
const response = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
@@ -363,6 +366,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
queryData.builderQueryType,
],
);
@@ -496,10 +500,11 @@ function QuerySearch({
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await getValueSuggestions({
: await fetchFieldValuesForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
@@ -604,6 +609,7 @@ function QuerySearch({
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
queryData.builderQueryType,
],
);

View File

@@ -0,0 +1,215 @@
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

@@ -0,0 +1,111 @@
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 } = query;
const { dataSource, builderQueryType } = query;
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -94,8 +94,9 @@ export const QueryV2 = forwardRef(function QueryV2(
);
const showSpanScopeSelector = useMemo(
() => dataSource === DataSource.TRACES,
[dataSource],
() =>
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
[dataSource, builderQueryType],
);
const showInlineQuerySearch = useMemo(() => {

View File

@@ -348,6 +348,19 @@ 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,7 +11,7 @@ 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 { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
@@ -51,7 +51,7 @@ import {
} from 'utils/explorerUtils';
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
@@ -88,7 +88,7 @@ function Explorer(): JSX.Element {
const listQueryKeyRef = useRef<any>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
@@ -118,8 +118,8 @@ function Explorer(): JSX.Element {
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueriesMap.traces,
PANEL_TYPES.LIST,
initialQueryAIWithType,
DEFAULT_PANEL_TYPE,
DataSource.TRACES,
),
[updateAllQueriesOperators],
@@ -185,8 +185,8 @@ function Explorer(): JSX.Element {
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueriesMap.traces,
panelType || PANEL_TYPES.LIST,
stagedQuery || initialQueryAIWithType,
panelType || DEFAULT_PANEL_TYPE,
),
[stagedQuery, panelType],
);

View File

@@ -17,7 +17,7 @@ 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 { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
@@ -94,7 +94,7 @@ function ListView({
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
);

View File

@@ -1,42 +1,25 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, 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';
import { DEFAULT_PANEL_TYPE } from '../constants';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
limit: { isHidden: false, isDisabled: true },
having: { isHidden: false, isDisabled: true },
}),
[],
);
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],
@@ -45,14 +28,10 @@ function QuerySection(): JSX.Element {
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
showOnlyWhereClause={isListViewPanel}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);

View File

@@ -14,7 +14,7 @@ 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 { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
@@ -60,7 +60,7 @@ function TracesView({
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
[stagedQuery],
);

View File

@@ -1,3 +1,7 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
export const TOOLBAR_VIEWS = {
list: {
name: 'list',

View File

@@ -37,11 +37,13 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
compositeQuery.queries?.forEach((q) => {
const spec = q.spec as BuilderQuery | PromQuery | ClickHouseQuery;
if (q.type === 'builder_query') {
if (q.type === 'builder_query' || q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = convertBuilderQueryToIBuilderQuery(
spec as BuilderQuery,
);
builderQueries[spec.name] = {
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
builderQueryType: q.type,
};
// Both share the builder bucket; the AI variant rides on the query itself.
builderQueryTypes[spec.name] = 'builder_query';
}
} else if (q.type === 'builder_formula') {

View File

@@ -5,7 +5,7 @@ import {
useMemo,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Atom, Sparkles, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import cx from 'classnames';
import { Typography } from '@signozhq/ui/typography';
@@ -21,15 +21,24 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
supportsAIQuery,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import {
AI_QUERY_TAB,
type QueryTabKey,
resolveActiveQueryTab,
toAIQuery,
withAIQueryType,
} from './utils';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -69,11 +78,20 @@ function PanelEditorQueryBuilder({
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
// The AI tab is not a query type — it stamps `builderQueryType` onto the builder
// queries (and pins them to traces, the only signal AI queries support).
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
(nextTab: string): void => {
if (nextTab === AI_QUERY_TAB) {
redirectWithQueryBuilderData({
...toAIQuery(currentQuery),
queryType: EQueryType.QUERY_BUILDER,
});
return;
}
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
...withAIQueryType(currentQuery, false),
queryType: nextTab as EQueryType,
});
},
[currentQuery, redirectWithQueryBuilderData],
@@ -101,9 +119,32 @@ function PanelEditorQueryBuilder({
);
const items = useMemo(() => {
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const supportedQueryTypes: QueryTabKey[] = getSupportedQueryTypes(panelKind);
const supportedTabs = supportsAIQuery(panelKind)
? [...supportedQueryTypes, AI_QUERY_TAB]
: supportedQueryTypes;
const queryTypeComponents = {
[AI_QUERY_TAB]: {
icon: <Sparkles size={14} />,
label: 'AI Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
config={{
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
}}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
queryComponents={{}}
savePreviousQuery
/>
</div>
),
},
[EQueryType.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
@@ -138,15 +179,15 @@ function PanelEditorQueryBuilder({
},
};
return supportedQueryTypes.map((queryType) => ({
key: queryType,
return supportedTabs.map((tabKey) => ({
key: tabKey,
label: (
<div className={styles.queryTypeTab}>
{queryTypeComponents[queryType].icon}
<Typography>{queryTypeComponents[queryType].label}</Typography>
{queryTypeComponents[tabKey].icon}
<Typography>{queryTypeComponents[tabKey].label}</Typography>
</div>
),
children: queryTypeComponents[queryType].component,
children: queryTypeComponents[tabKey].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode]);
@@ -163,7 +204,7 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={currentQuery.queryType}
activeKey={resolveActiveQueryTab(currentQuery)}
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>

View File

@@ -3,6 +3,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
@@ -61,6 +62,7 @@ function lastQueryBuilderProps(): {
panelType: string;
isListViewPanel: boolean;
filterConfigs: unknown;
config?: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
return calls[calls.length - 1][0];
@@ -70,15 +72,20 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
});
it('shows only the Query Builder tab for the List kind', () => {
it('shows only the Query Builder tabs for the List kind', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
@@ -91,21 +98,62 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows all three tabs for the Time Series kind', () => {
it('shows all four tabs for the Time Series kind', () => {
renderBuilder('signoz/TimeSeriesPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
// The AI tab is derived from `builderQueryType`, not from a stored tab key.
it('activates the AI tab when the builder query carries the AI envelope tag', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
renderBuilder('signoz/TimeSeriesPanel');
expect(
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
).toBeInTheDocument();
});
it('pins the AI tab builder to traces so the signal cannot be changed', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
renderBuilder('signoz/TimeSeriesPanel');
expect(lastQueryBuilderProps().config).toStrictEqual({
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
});
});
});
describe('PanelEditorQueryBuilder field visibility (driven by the capabilities guard)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
});

View File

@@ -0,0 +1,140 @@
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
AI_QUERY_TAB,
isAIQuery,
resolveActiveQueryTab,
toAIQuery,
withAIQueryType,
} from '../utils';
function makeQuery(
queryData: Record<string, unknown>[],
queryType: EQueryType = EQueryType.QUERY_BUILDER,
): Query {
return {
queryType,
builder: { queryData, queryFormulas: [], queryTraceOperator: [] },
promql: [],
clickhouse_sql: [],
id: 'test',
} as unknown as Query;
}
describe('isAIQuery', () => {
it('is true when any builder query carries the AI envelope tag', () => {
expect(
isAIQuery(
makeQuery([{ queryName: 'A' }, { builderQueryType: 'builder_ai_query' }]),
),
).toBe(true);
});
it('is false for plain builder queries and for an empty builder', () => {
expect(isAIQuery(makeQuery([{ queryName: 'A' }]))).toBe(false);
expect(isAIQuery(makeQuery([]))).toBe(false);
});
});
describe('resolveActiveQueryTab', () => {
it('selects the AI tab for a tagged builder query', () => {
expect(
resolveActiveQueryTab(makeQuery([{ builderQueryType: 'builder_ai_query' }])),
).toBe(AI_QUERY_TAB);
});
it('selects the query type for an untagged query', () => {
expect(resolveActiveQueryTab(makeQuery([{ queryName: 'A' }]))).toBe(
EQueryType.QUERY_BUILDER,
);
});
// A PromQL panel reads its queries from a different bucket, so a stale tag on the
// builder bucket must not steal the active tab.
it('keeps PromQL selected even if the builder bucket carries a tag', () => {
expect(
resolveActiveQueryTab(
makeQuery([{ builderQueryType: 'builder_ai_query' }], EQueryType.PROM),
),
).toBe(EQueryType.PROM);
});
});
describe('toAIQuery', () => {
// The backend decodes a builder_ai_query spec as QueryBuilderQuery[TraceAggregation],
// which has no `metricName` — a carried-over metrics aggregation fails the request.
it('re-seeds a metrics query onto traces, dropping the metric aggregation', () => {
const result = toAIQuery(
makeQuery([
{
queryName: 'A',
dataSource: DataSource.METRICS,
aggregations: [{ metricName: 'signoz_latency_bucket' }],
},
]),
);
const [queryData] = result.builder.queryData;
expect(queryData.dataSource).toBe(DataSource.TRACES);
expect(queryData.aggregations).toStrictEqual([{ expression: 'count() ' }]);
expect(queryData.builderQueryType).toBe('builder_ai_query');
});
it('keeps the filter on a query already using traces', () => {
const result = toAIQuery(
makeQuery([
{
queryName: 'A',
dataSource: DataSource.TRACES,
filter: { expression: "service.name = 'checkout'" },
},
]),
);
expect(result.builder.queryData[0].filter).toStrictEqual({
expression: "service.name = 'checkout'",
});
expect(result.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
});
it('preserves the query name when re-seeding', () => {
const result = toAIQuery(
makeQuery([{ queryName: 'B', dataSource: DataSource.LOGS }]),
);
expect(result.builder.queryData[0].queryName).toBe('B');
});
});
describe('withAIQueryType', () => {
it('stamps the tag onto every builder query', () => {
const result = withAIQueryType(
makeQuery([{ queryName: 'A' }, { queryName: 'B' }]),
true,
);
expect(
result.builder.queryData.map((item) => item.builderQueryType),
).toStrictEqual(['builder_ai_query', 'builder_ai_query']);
});
it('deletes the key when clearing, rather than setting undefined', () => {
const result = withAIQueryType(
makeQuery([{ queryName: 'A', builderQueryType: 'builder_ai_query' }]),
false,
);
expect(result.builder.queryData[0]).not.toHaveProperty('builderQueryType');
expect(result.builder.queryData[0]).toStrictEqual({ queryName: 'A' });
});
it('returns the query untouched when it already matches', () => {
const tagged = makeQuery([{ builderQueryType: 'builder_ai_query' }]);
const plain = makeQuery([{ queryName: 'A' }]);
expect(withAIQueryType(tagged, true)).toBe(tagged);
expect(withAIQueryType(plain, false)).toBe(plain);
});
});

View File

@@ -0,0 +1,91 @@
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
/**
* Tab key for the AI query builder. Deliberately not an `EQueryType`: an AI query is
* a builder query carrying `builderQueryType: 'builder_ai_query'`, so the query type
* on the wire stays `builder` and only the per-query envelope tag differs. Keeping the
* tab out of the enum leaves that tag the single source of truth.
*/
export const AI_QUERY_TAB = 'ai_builder' as const;
export type QueryTabKey = EQueryType | typeof AI_QUERY_TAB;
export function isAIQuery(query: Query): boolean {
return query.builder.queryData.some(
(item) => item.builderQueryType === 'builder_ai_query',
);
}
/** The tab to highlight — derived from the queries, never stored separately. */
export function resolveActiveQueryTab(query: Query): QueryTabKey {
return query.queryType === EQueryType.QUERY_BUILDER && isAIQuery(query)
? AI_QUERY_TAB
: query.queryType;
}
/** Carried across a signal switch, mirroring the builder's own datasource selector. */
const PRESERVED_ON_SIGNAL_SWITCH = ['queryName', 'expression'];
/**
* Re-seed a non-traces query with the traces defaults, the way `handleChangeDataSource`
* does. AI queries are traces-only, and a leftover metrics aggregation carries
* `metricName` — a field the backend rejects on a trace spec. A query already on traces
* keeps its filters, so switching tabs on a trace query is non-destructive.
*/
function toTracesQueryData(item: IBuilderQuery): IBuilderQuery {
if (item.dataSource === DataSource.TRACES) {
return item;
}
const tracesDefaults = Object.fromEntries(
Object.entries(initialQueryBuilderFormValuesMap[DataSource.TRACES]).filter(
([key]) => !PRESERVED_ON_SIGNAL_SWITCH.includes(key),
),
);
return { ...item, ...tracesDefaults, dataSource: DataSource.TRACES };
}
/** Move a query onto the AI builder: pin every query to traces and tag it. */
export function toAIQuery(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({
...toTracesQueryData(item),
builderQueryType: 'builder_ai_query' as const,
})),
},
};
}
/**
* Stamp or clear `builderQueryType` across every builder query. Returns the query
* untouched when it already matches, and deletes the key rather than setting it to
* `undefined` — the dirty checks compare by value, so a stray key reads as an edit.
*/
export function withAIQueryType(query: Query, enabled: boolean): Query {
const needsUpdate = query.builder.queryData.some(
(item) => (item.builderQueryType === 'builder_ai_query') !== enabled,
);
if (!needsUpdate) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item): IBuilderQuery => {
const { builderQueryType: _dropped, ...rest } = item;
return enabled ? { ...rest, builderQueryType: 'builder_ai_query' } : rest;
}),
},
};
}

View File

@@ -5,7 +5,10 @@ import { handleQueryChange } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../../Panels/capabilities';
import {
resolveQueryType,
supportsAIQuery,
} from '../../../Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
@@ -19,6 +22,7 @@ jest.mock('container/NewWidget/utils', () => ({
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
supportsAIQuery: jest.fn(),
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
@@ -33,6 +37,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
const mockSupportsAIQuery = supportsAIQuery as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
@@ -96,7 +101,11 @@ describe('usePanelTypeSwitch', () => {
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
const state = builderState({
id: 'q',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
@@ -114,7 +123,11 @@ describe('usePanelTypeSwitch', () => {
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const tableQuery = {
id: 'table-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
@@ -142,7 +155,11 @@ describe('usePanelTypeSwitch', () => {
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
builderState({
id: 'ts-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -169,7 +186,11 @@ describe('usePanelTypeSwitch', () => {
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
const promQuery = {
id: 'prom',
queryType: 'promql',
builder: { queryData: [] },
} as unknown as Query;
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
@@ -191,10 +212,88 @@ describe('usePanelTypeSwitch', () => {
expect((queryArg as Query).queryType).toBe('builder');
});
// `handleQueryChange` rebuilds from a field allow-list that omits `builderQueryType`,
// so the tag has to be re-applied after the rebuild or the AI tab silently reverts.
it('re-applies the AI envelope tag when the new kind supports AI queries', () => {
const setSpec = jest.fn();
mockSupportsAIQuery.mockReturnValue(true);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }] },
} as unknown as Query);
const aiQuery = {
id: 'ai-current',
queryType: 'builder',
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
} as unknown as Query;
const state = builderState(aiQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('drops the AI envelope tag when the new kind has no AI tab', () => {
const setSpec = jest.fn();
mockSupportsAIQuery.mockReturnValue(false);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }] },
} as unknown as Query);
const aiQuery = {
id: 'ai-current',
queryType: 'builder',
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
} as unknown as Query;
const state = builderState(aiQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The rebuild receives an untagged query…
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).builder.queryData[0]).not.toHaveProperty(
'builderQueryType',
);
// …and nothing re-applies it afterwards.
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
});
it('restores the original kind verbatim on switch-back (reversibility)', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
const tableQuery = {
id: 'table-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const listQuery = {
id: 'list-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);

View File

@@ -18,7 +18,7 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import { resolveQueryType, supportsAIQuery } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -29,6 +29,7 @@ import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
import { isAIQuery, withAIQueryType } from '../PanelEditorQueryBuilder/utils';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
@@ -139,16 +140,24 @@ export function usePanelTypeSwitch({
// First visit → coerce the query type if the new kind disallows it, then
// rebuild the builder query for the new type.
const queryType = resolveQueryType(newKind, query.queryType);
// AI-ness rides on the query, not on `queryType`, so `resolveQueryType` can't
// see it: carry it across only when the new kind has an AI tab to surface it.
const keepAIQueryType = supportsAIQuery(newKind) && isAIQuery(query);
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
{ ...withAIQueryType(query, false), queryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
const reordered =
newPanelType === PANEL_TYPES.LIST
? withDefaultListOrder(transformed)
: transformed;
// `handleQueryChange` rebuilds each query from an allow-list of fields that
// doesn't include `builderQueryType`, so re-stamp it after the rebuild.
const nextQuery = keepAIQueryType
? withAIQueryType(reordered, true)
: reordered;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;

View File

@@ -39,6 +39,15 @@ export function isQueryTypeSupportedByPanelKind(
return getSupportedQueryTypes(kind).includes(queryType);
}
/**
* Whether a kind offers the AI query builder. Separate from `supportedQueryTypes`
* because an AI query is a builder query carrying `builderQueryType`, not its own
* `EQueryType` — the tab is UI state, the wire type stays `builder`.
*/
export function supportsAIQuery(kind: PanelKind): boolean {
return getPanelDefinition(kind).supportsAIQuery === true;
}
/**
* Master guard: is this panel kind renderable with this query type (and, in builder
* mode, this signal)? ClickHouse/PromQL queries carry no signal, so the signal is

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
// list configs — the capabilities guard is the single source for both.
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
supportsAIQuery: true,
queryBuilderFields: {
default: {
stepInterval: { isHidden: true, isDisabled: true },

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -15,6 +15,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -15,6 +15,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportsAIQuery: true,
queryBuilderFields: {},
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -48,6 +48,8 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
supportedQueryTypes: EQueryType[];
/** Kind offers the AI query builder — a traces-only builder variant, not its own query language. */
supportsAIQuery?: boolean;
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
queryBuilderFields: QueryBuilderFieldRule;
actions: PanelActionCapabilities;

View File

@@ -5,8 +5,8 @@ import type {
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Flattens a panel's queries into its builder queries, unwrapping
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
* Flattens a panel's queries into its builder queries (`builder_query` and its AI
* variant), unwrapping `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
* TraceOperator) are dropped — they lack the legend/groupBy/aggregation context
* downstream code needs. Returns the generated v5 `BuilderQuery` shape directly.
*/
@@ -22,7 +22,7 @@ export function getBuilderQueries(
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries || []).forEach((sub) => {
if (sub.type === 'builder_query') {
if (sub.type === 'builder_query' || sub.type === 'builder_ai_query') {
flattened.push(sub.spec as BuilderQuery);
}
});

View File

@@ -2,7 +2,11 @@ import type {
DashboardtypesQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialQueriesMap,
initialQueryAIWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -171,6 +175,23 @@ describe('persesQueryAdapters', () => {
);
});
it('preserves an AI builder query through toPerses → fromPerses', () => {
const original: Query = initialQueryAIWithType;
const perses = toPerses(original, PANEL_TYPES.TIME_SERIES);
const { queries } = perses[0].spec.plugin.spec as {
queries: Querybuildertypesv5QueryEnvelopeDTO[];
};
expect(queries[0].type).toBe('builder_ai_query');
const restored = fromPerses(perses, PANEL_TYPES.TIME_SERIES);
expect(restored.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(restored.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('preserves a List builder query through toPerses → fromPerses', () => {
const original: Query = initialQueriesMap[DataSource.LOGS];

View File

@@ -475,6 +475,7 @@ 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

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

View File

@@ -16,6 +16,7 @@ export type RequestType =
export type QueryType =
| 'builder_query'
| 'builder_ai_query'
| 'builder_trace_operator'
| 'builder_formula'
| 'builder_sub_query'
@@ -23,6 +24,11 @@ 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';