Compare commits

...

10 Commits

Author SHA1 Message Date
Gaurav Tewari
ef1cf7cd14 Merge branch 'feat/ai-query-support' into feat/ai-edit-support 2026-09-20 22:31:23 +05:30
Gaurav Tewari
49164911f4 chore: add support for list as well and self review changes 2026-09-20 22:28:07 +05:30
Gaurav Tewari
98b8e223b6 chore: add support for signoz/signoz/AIBuilderQuery 2026-09-20 19:57:56 +05:30
Gaurav Tewari
9cfd5b3421 feat(dashboards): author AI queries in the V2 panel editor
Replaces the panel registry's two independent lists (supportedSignals,
supportedQueryTypes) with a single supportedQueryModes map. Two lists
cannot express that AI is traces-only on a kind whose builder mode also
takes logs and metrics; declaring modes and their signals together can.

The AI mode is not an EQueryType — it is a per-query tag, so the active
tab is derived from the queries rather than stored.

Assisted-by: Claude Opus 5
2026-09-20 18:17:35 +05:30
Gaurav Tewari
7b3a0e038b feat(dashboards): render AI query panels
Treats `builder_ai_query` as a builder envelope everywhere the read path
already handled `builder_query`, so an AI query exported from the AI
explorer survives the round trip and renders. Authoring it in the panel
editor is a follow-up.

Assisted-by: Claude Opus 5
2026-09-20 18:14:28 +05:30
Naman Verma
2d48967690 fix: regen api spec 2026-09-17 11:30:32 +05:30
Naman Verma
c29b7b492f test: unit test cleanup 2026-09-17 11:26:16 +05:30
Naman Verma
d49919053f Merge branch 'main' into nv/dashboard-ai-builder-query 2026-09-17 11:20:35 +05:30
Gaurav Tewari
e504c4081e feat/add support for fetch AI fieldKeys in order by and options (#12859)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

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

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

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

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

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

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-17 05:10:20 +00:00
Naman Verma
67878a01b2 feat: add ai builder query plugin kind 2026-09-10 12:08:52 +05:30
87 changed files with 2412 additions and 427 deletions

View File

@@ -3210,6 +3210,69 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAIBuilderQuerySpec:
properties:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
type: boolean
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
legend:
type: string
limit:
type: integer
limitBy:
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
name:
type: string
offset:
type: integer
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
secondaryAggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
nullable: true
type: array
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
enum:
- traces
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
DashboardtypesAxes:
properties:
isLogScale:
@@ -4133,6 +4196,7 @@ components:
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -4142,6 +4206,7 @@ components:
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
@@ -4151,12 +4216,25 @@ components:
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
- signoz/AIBuilderQuery
- signoz/CompositeQuery
- signoz/Formula
- signoz/PromQLQuery
- signoz/ClickHouseSQL
- signoz/TraceOperator
type: string
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
properties:
kind:
enum:
- signoz/AIBuilderQuery
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
properties:
kind:

View File

@@ -4009,6 +4009,71 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export enum DashboardtypesAIBuilderQuerySpecDTOSignal {
traces = 'traces',
}
export interface DashboardtypesAIBuilderQuerySpecDTO {
/**
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
cursor?: string;
/**
* @type boolean
*/
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
*/
legend?: string;
/**
* @type integer
*/
limit?: number;
limitBy?: Querybuildertypesv5LimitByDTO;
/**
* @type string
*/
name?: string;
/**
* @type integer
*/
offset?: number;
/**
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
/**
* @type array,null
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
/**
* @type array,null
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @enum traces
* @type string
*/
signal: DashboardtypesAIBuilderQuerySpecDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -5102,6 +5167,18 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesBuilderQuerySpecDTO;
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind {
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
}
export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO {
/**
* @enum signoz/AIBuilderQuery
* @type string
*/
kind: DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind;
spec: DashboardtypesAIBuilderQuerySpecDTO;
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind {
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
}
@@ -5393,6 +5470,7 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQu
export type DashboardtypesQueryPluginDTO =
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormulaDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQueryDTO
@@ -6069,6 +6147,7 @@ export interface DashboardtypesPostablePublicDashboardDTO {
export enum DashboardtypesQueryPluginKindDTO {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
'signoz/Formula' = 'signoz/Formula',
'signoz/PromQLQuery' = 'signoz/PromQLQuery',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,8 @@ export enum LOCALSTORAGE {
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { mergeExtraFields } from 'utils/extraFields';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
hideColumn,
initializeFromDefaults,
setColumnOrder,
showColumn,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import { buildTraceViewColumns, TRACE_ID_COLUMN_ID } from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
interface UseTraceViewColumns {
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
},
TRACE_VIEW_BUILDER_QUERY_TYPE,
);
const availableFields = useMemo(
() => mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isFetched) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isFetched, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
showColumn(STORAGE_KEY, column.id);
} else {
hideColumn(STORAGE_KEY, column.id);
}
});
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns],
);
return {
columns,
selectedFields,
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
};
}

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,10 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
> = {};
const builderQueryTypes: Record<
string,
'builder_query' | 'builder_formula' | 'builder_trace_operator'
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
> = {};
const promQueries: IPromQLQuery[] = [];
const clickhouseQueries: IClickHouseQuery[] = [];
@@ -44,6 +47,14 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
);
builderQueryTypes[spec.name] = 'builder_query';
}
} else if (q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = {
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
builderQueryType: 'builder_ai_query',
};
builderQueryTypes[spec.name] = 'builder_ai_query';
}
} else if (q.type === 'builder_formula') {
if (spec.name) {
builderQueries[spec.name] = convertQueryBuilderFormulaToIBuilderFormula(

View File

@@ -15,7 +15,10 @@ export const transformQueryBuilderDataModel = (
data: BuilderQueryDataResourse,
queryTypes?: Record<
string,
'builder_query' | 'builder_formula' | 'builder_trace_operator'
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
>,
): QueryBuilderData => {
const queryData: QueryBuilderData['queryData'] = [];

View File

@@ -1,3 +1,7 @@
import {
isBuilderEnvelope,
isBuilderPluginKind,
} from '../../../queryV5/builderEnvelope';
import type {
DashboardtypesDashboardSpecDTOPanels,
DashboardtypesQueryDTO,
@@ -20,15 +24,13 @@ function forEachBuilderSpec(
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
(composite.queries ?? [])
.filter((envelope) => envelope.type === 'builder_query')
.forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (plugin.kind === 'signoz/BuilderQuery') {
(composite.queries ?? []).filter(isBuilderEnvelope).forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (isBuilderPluginKind(plugin.kind)) {
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
}

View File

@@ -11,6 +11,7 @@ import {
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { isBuilderEnvelope } from '../../../queryV5/builderEnvelope';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { getTextPanelBody } from './getTextPanelBody';
import { dtoToFormModel } from '../variableAdapters';
@@ -54,7 +55,7 @@ function envelopeReferenceText(
const spec = envelope.spec as
| { query?: string; filter?: { expression?: string } }
| undefined;
if (envelope.type === 'builder_query') {
if (isBuilderEnvelope(envelope)) {
const text = spec?.filter?.expression;
return typeof text === 'string' ? { kind: 'builder', text } : null;
}
@@ -232,7 +233,7 @@ export function findApplyUsages(
});
};
if (envelope.type === 'builder_query') {
if (isBuilderEnvelope(envelope)) {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;
@@ -294,7 +295,7 @@ export function isVariableAppliedToAllPanels(
return true;
}
return toQueryEnvelopes(queries).every((envelope) => {
if (envelope.type === 'builder_query') {
if (isBuilderEnvelope(envelope)) {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;

View File

@@ -7,8 +7,8 @@ import type {
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/types/queryModes';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
@@ -30,7 +30,7 @@ interface ConfigPaneProps {
* panel types the visualization switcher disables — read from the provider, not the
* spec, because a new panel's spec has no query until staged.
*/
queryType: EQueryType;
mode: PanelQueryMode;
/** Panel's resolved series, provided to sections that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
@@ -57,7 +57,7 @@ function ConfigPane({
spec,
onChangeSpec,
onChangePanelKind,
queryType,
mode,
legendSeries,
tableColumns,
stepInterval,
@@ -125,7 +125,7 @@ function ConfigPane({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
mode={mode}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
@@ -149,7 +149,7 @@ function ConfigPane({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
mode={mode}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>

View File

@@ -1,8 +1,8 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { PanelQueryMode } from '../../../Panels/types/queryModes';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
@@ -11,8 +11,8 @@ import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
panelKind: PanelKind;
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). */
queryType: EQueryType;
/** Active query mode — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse/AI disable it). */
mode: PanelQueryMode;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
onChange: (kind: PanelKind) => void;
@@ -26,11 +26,11 @@ interface PanelTypeSwitcherProps {
*/
function PanelTypeSwitcher({
panelKind,
queryType,
mode,
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const items = usePanelTypeSelectItems({ mode, signal });
return (
<div className={styles.field}>

View File

@@ -43,18 +43,28 @@ describe('PanelTypeSwitcher', () => {
jest.clearAllMocks();
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
supportedSignals:
mockGetPanelDefinition.mockImplementation((kind: string) => {
const signals =
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'],
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
: ['metrics', 'logs', 'traces'];
const queryTypes = SUPPORTED_QUERY_TYPES[kind] ?? [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
}));
];
return {
mode: 'query',
supportedQueryModes: Object.fromEntries(
queryTypes.map((queryType: EQueryType) => [
queryType,
queryType === EQueryType.QUERY_BUILDER
? { kind: 'signal', signals }
: { kind: 'signal-less' },
]),
),
};
});
});
it('fires onChange with the chosen plugin kind', () => {
@@ -62,7 +72,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
mode={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
@@ -77,7 +87,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
mode={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
@@ -93,7 +103,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
mode={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);
@@ -108,7 +118,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
mode={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
@@ -125,7 +135,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
mode={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);

View File

@@ -11,14 +11,14 @@ describe('getPanelTypeDisabledReason', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TimeSeriesPanel',
queryType: PROM,
mode: PROM,
label: 'Time Series',
}),
).toBeUndefined();
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
signal: logs,
label: 'List',
}),
@@ -29,21 +29,21 @@ describe('getPanelTypeDisabledReason', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: PROM,
mode: PROM,
label: 'List',
}),
).toBe("List isn't available for PromQL queries");
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: CLICKHOUSE,
mode: CLICKHOUSE,
label: 'List',
}),
).toBe("List isn't available for ClickHouse queries");
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TablePanel',
queryType: PROM,
mode: PROM,
label: 'Table',
}),
).toBe("Table isn't available for PromQL queries");
@@ -53,7 +53,7 @@ describe('getPanelTypeDisabledReason', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
signal: metrics,
label: 'List',
}),
@@ -64,7 +64,7 @@ describe('getPanelTypeDisabledReason', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: PROM,
mode: PROM,
signal: metrics,
label: 'List',
}),

View File

@@ -1,16 +1,16 @@
import { useMemo } from 'react';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import { PANEL_OPTIONS } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { PanelQueryMode } from '../../../Panels/types/queryModes';
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
import { getPanelTypeDisabledReason } from './utils';
interface UsePanelTypeSelectItemsArgs {
/** Active query type — a kind that can't be authored in it is disabled. */
queryType: EQueryType;
/** Active query mode — a kind that can't be authored in it is disabled. */
mode: PanelQueryMode;
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
}
@@ -22,7 +22,7 @@ interface UsePanelTypeSelectItemsArgs {
* modal's header so the two selectors apply the same rule and can't drift.
*/
export function usePanelTypeSelectItems({
queryType,
mode,
signal,
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
return useMemo(
@@ -31,7 +31,7 @@ export function usePanelTypeSelectItems({
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind,
queryType,
mode,
signal,
label: displayName,
});
@@ -43,6 +43,6 @@ export function usePanelTypeSelectItems({
tooltip: disabledReason,
};
}),
[queryType, signal],
[mode, signal],
);
}

View File

@@ -3,10 +3,14 @@ import { EQueryType } from 'types/common/dashboard';
import {
isStaticPanelKind,
isQueryTypeSupportedByPanelKind,
isQueryModeSupportedByPanelKind,
isSignalSupported,
} from '../../../Panels/capabilities';
import type { PanelKind } from '../../../Panels/types/panelKind';
import {
AI_QUERY_MODE,
type PanelQueryMode,
} from '../../../Panels/types/queryModes';
const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
[EQueryType.QUERY_BUILDER]: 'Query Builder',
@@ -14,6 +18,11 @@ const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
[EQueryType.PROM]: 'PromQL',
};
const MODE_LABEL: Record<PanelQueryMode, string> = {
...QUERY_TYPE_LABEL,
[AI_QUERY_MODE]: 'AI Query Builder',
};
const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
[TelemetrytypesSignalDTO.logs]: 'logs',
[TelemetrytypesSignalDTO.traces]: 'traces',
@@ -29,12 +38,12 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
*/
export function getPanelTypeDisabledReason({
kind,
queryType,
mode,
signal,
label,
}: {
kind: PanelKind;
queryType: EQueryType;
mode: PanelQueryMode;
signal?: TelemetrytypesSignalDTO;
label: string;
}): string | undefined {
@@ -44,10 +53,10 @@ export function getPanelTypeDisabledReason({
if (isStaticPanelKind(kind)) {
return undefined;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
if (!isQueryModeSupportedByPanelKind(kind, mode)) {
return `${label} isn't available for ${MODE_LABEL[mode]} queries`;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {
if (signal !== undefined && !isSignalSupported(kind, signal, mode)) {
return `${label} doesn't support ${SIGNAL_LABEL[signal]} data`;
}
return undefined;

View File

@@ -58,7 +58,7 @@ function SectionSlot({
signal,
panelKind,
onChangePanelKind,
queryType,
mode,
stepInterval,
metricUnit,
}: SectionSlotProps): JSX.Element | null {
@@ -124,7 +124,7 @@ function SectionSlot({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
mode={mode}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}

View File

@@ -49,7 +49,7 @@ function renderConfigPane(
spec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
queryType: EQueryType.QUERY_BUILDER,
mode: EQueryType.QUERY_BUILDER,
legendSeries: [],
tableColumns: [],
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,

View File

@@ -1,9 +1,9 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryMode } from '../../Panels/types/queryModes';
import type { PanelKind } from '../../Panels/types/panelKind';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import { EQueryType } from 'types/common/dashboard';
/**
* Context `SectionSlot` forwards to every section editor (not spec-slice fields — those
@@ -17,7 +17,7 @@ export interface SectionEditorContext {
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
yAxisUnit?: string;
queryType?: EQueryType;
mode?: PanelQueryMode;
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;

View File

@@ -16,7 +16,7 @@ import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
Pick<
SectionEditorContext,
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
'panelKind' | 'onChangePanelKind' | 'signal' | 'mode'
>;
/**
@@ -31,7 +31,7 @@ function VisualizationSection({
onChange,
panelKind,
onChangePanelKind,
queryType,
mode,
signal,
}: VisualizationSectionProps): JSX.Element {
return (
@@ -39,9 +39,9 @@ function VisualizationSection({
{controls.switchPanelKind && panelKind && onChangePanelKind && (
<PanelTypeSwitcher
panelKind={panelKind}
// queryType is optional on the kind-erased section context, but always
// mode is optional on the kind-erased section context, but always
// supplied in practice; default to Query Builder at this boundary.
queryType={queryType ?? EQueryType.QUERY_BUILDER}
mode={mode ?? EQueryType.QUERY_BUILDER}
signal={signal}
onChange={onChangePanelKind}
/>

View File

@@ -9,8 +9,11 @@ import VisualizationSection from '../VisualizationSection';
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
supportedQueryModes: {
builder: { kind: 'signal', signals: ['metrics', 'logs', 'traces'] },
clickhouse_sql: { kind: 'signal-less' },
promql: { kind: 'signal-less' },
},
})),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },

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';
@@ -18,12 +18,20 @@ import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL'
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import {
AI_QUERY_MODE,
listQueryModes,
type PanelQueryMode,
} from '../../Panels/types/queryModes';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toPanelType } from '../../Panels/types/panelKind';
import { getQueryMode, withQueryMode } from '../../Panels/utils/queryMode';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -66,13 +74,18 @@ function PanelEditorQueryBuilder({
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
// Derived, not stored: the AI mode is a per-query tag, so the active tab is whatever the
// queries currently say. A mode the kind no longer offers (after a kind switch) falls back
// to the builder rather than selecting a tab that isn't rendered.
const activeMode = getQueryMode(currentQuery);
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
});
(mode: string): void => {
redirectWithQueryBuilderData(
withQueryMode(currentQuery, mode as PanelQueryMode),
);
},
[currentQuery, redirectWithQueryBuilderData],
);
@@ -99,7 +112,9 @@ function PanelEditorQueryBuilder({
);
const items = useMemo(() => {
const { supportedQueryTypes } = panelDefinition;
const supportedModes = listQueryModes(
panelDefinition.supportedQueryModes,
).filter((mode) => mode !== AI_QUERY_MODE || isAIObservabilityEnabled);
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
@@ -134,19 +149,48 @@ function PanelEditorQueryBuilder({
label: 'PromQL',
component: <PromQLQueryContainer />,
},
// Traces only, and the source selector is hidden with it: an AI query that moved
// off traces is no longer an AI query. `queryVariant: 'static'` matches the AI
// explorer's builder.
[AI_QUERY_MODE]: {
icon: <Sparkles size={14} />,
label: 'AI Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={false}
version="v3"
queryComponents={{}}
config={{
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
}}
/>
</div>
),
},
};
return supportedQueryTypes.map((queryType) => ({
key: queryType,
return supportedModes.map((mode) => ({
key: mode,
label: (
<div className={styles.queryTypeTab}>
{queryTypeComponents[queryType].icon}
<Typography>{queryTypeComponents[queryType].label}</Typography>
{queryTypeComponents[mode].icon}
<Typography>{queryTypeComponents[mode].label}</Typography>
</div>
),
children: queryTypeComponents[queryType].component,
children: queryTypeComponents[mode].component,
}));
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [
panelDefinition,
panelType,
filterConfigs,
isDarkMode,
isListViewPanel,
isAIObservabilityEnabled,
]);
return (
<div
@@ -161,7 +205,7 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={currentQuery.queryType}
activeKey={activeMode}
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>

View File

@@ -12,6 +12,10 @@ import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
const mockQueryBuilderV2 = jest.fn();
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => false,
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));

View File

@@ -21,6 +21,7 @@ import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import { getQueryMode } from '../Panels/utils/queryMode';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
@@ -319,7 +320,7 @@ function QueryEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
mode={getQueryMode(currentQuery)}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}

View File

@@ -122,7 +122,7 @@ function StaticEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={EQueryType.QUERY_BUILDER}
mode={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}
/>

View File

@@ -23,6 +23,10 @@ import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContai
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => false,
}));
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory: useRouterHistory } =
jest.requireActual('react-router-dom');

View File

@@ -5,7 +5,7 @@ import { handleQueryChange } from 'lib/query/panelQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../../Panels/capabilities';
import { resolveQueryMode } from '../../../Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
@@ -18,7 +18,7 @@ jest.mock('lib/query/panelQuery', () => ({
handleQueryChange: jest.fn(),
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
resolveQueryMode: jest.fn(),
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
@@ -36,7 +36,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 mockResolveQueryMode = resolveQueryMode 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;
@@ -95,7 +95,7 @@ describe('usePanelTypeSwitch', () => {
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
// The guard owns coercion (tested in capabilities.test.ts); here it always
// resolves to Query Builder so the coerced type flows into handleQueryChange.
mockResolveQueryType.mockReturnValue('builder');
mockResolveQueryMode.mockReturnValue('builder');
});
it('does nothing when switching to the current kind', () => {
@@ -185,16 +185,54 @@ describe('usePanelTypeSwitch', () => {
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The hook asks the guard to resolve the active query type against the new kind
expect(mockResolveQueryType).toHaveBeenCalledWith(
// The hook asks the guard to resolve the active mode against the new kind, passing
// the current signal — the AI mode is only valid for traces.
expect(mockResolveQueryMode).toHaveBeenCalledWith(
'signoz/ListPanel',
'promql',
'logs',
);
// …and the resolved type ('builder') flows into the query rebuild.
// …and the resolved mode ('builder') flows into the query rebuild.
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).queryType).toBe('builder');
});
it('strips the AI tag when the new kind has no AI mode', () => {
const setSpec = jest.fn();
const aiQuery = {
id: 'ai',
queryType: 'builder',
builder: {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
queryFormulas: [],
queryTraceOperator: [],
},
} as unknown as Query;
mockUseQueryBuilder.mockReturnValue(builderState(aiQuery));
// List declares no AI mode, so the guard coerces back to the builder.
mockResolveQueryMode.mockReturnValue('builder');
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
expect(mockResolveQueryMode).toHaveBeenCalledWith(
'signoz/ListPanel',
'builder_ai_query',
'logs',
);
// The tag has to come off the queries themselves — queryType alone can't say it.
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
(queryArg as Query).builder.queryData.forEach((qd) => {
expect(qd.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;

View File

@@ -4,7 +4,10 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import {
getSupportedSignals,
requireQueryPanelDefinition,
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
@@ -91,7 +94,7 @@ export function usePanelEditSession({
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelType = toPanelType(panelKind);
const defaultSignal = panelDefinition.supportedSignals[0];
const defaultSignal = getSupportedSignals(panelKind)[0];
const query = usePanelQuery({
panel: draft,

View File

@@ -18,7 +18,8 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
import { isStaticPanelKind, resolveQueryMode } from '../../Panels/capabilities';
import { getQueryMode, withQueryMode } from '../../Panels/utils/queryMode';
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
@@ -147,12 +148,16 @@ export function usePanelTypeSwitch({
return;
}
// 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);
// First visit → coerce the mode if the new kind disallows it, then rebuild the
// builder query for it. Going through the mode (not `queryType`) is what lets an
// AI query lose its per-query tag when the new kind has no AI mode: `queryType`
// alone cannot express that.
const currentSignal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
const mode = resolveQueryMode(newKind, getQueryMode(query), currentSignal);
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
withQueryMode(query, mode),
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
@@ -160,12 +165,9 @@ export function usePanelTypeSwitch({
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
getSwitchedPluginSpec(currentSpec, newKind, currentSignal),
toPerses(nextQuery, newPanelType),
),
);

View File

@@ -17,9 +17,12 @@ import {
getSupportedSignals,
isPanelCombinationValid,
isQueryTypeSupportedByPanelKind,
isQueryModeSupportedByPanelKind,
isSignalSupported,
resolveQueryMode,
resolveQueryType,
} from '../capabilities';
import { AI_QUERY_MODE } from '../types/queryModes';
import type { PanelKind } from '../types/panelKind';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
@@ -142,7 +145,7 @@ describe('panel capabilities guard', () => {
expect(getSupportedQueryTypes(unknownKind)).toStrictEqual([]);
expect(isSignalSupported(unknownKind, logs)).toBe(false);
expect(
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
isPanelCombinationValid({ kind: unknownKind, mode: QUERY_BUILDER }),
).toBe(false);
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
@@ -212,13 +215,13 @@ describe('panel capabilities guard', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/TimeSeriesPanel',
queryType: PROM,
mode: PROM,
}),
).toBe(true);
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
signal: logs,
}),
).toBe(true);
@@ -226,10 +229,10 @@ describe('panel capabilities guard', () => {
it('rejects an unsupported query type', () => {
expect(
isPanelCombinationValid({ kind: 'signoz/ListPanel', queryType: PROM }),
isPanelCombinationValid({ kind: 'signoz/ListPanel', mode: PROM }),
).toBe(false);
expect(
isPanelCombinationValid({ kind: 'signoz/TablePanel', queryType: PROM }),
isPanelCombinationValid({ kind: 'signoz/TablePanel', mode: PROM }),
).toBe(false);
});
@@ -237,7 +240,7 @@ describe('panel capabilities guard', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
signal: metrics,
}),
).toBe(false);
@@ -247,7 +250,7 @@ describe('panel capabilities guard', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
}),
).toBe(true);
});
@@ -268,6 +271,85 @@ describe('panel capabilities guard', () => {
});
});
describe('the AI query mode', () => {
const AI_KINDS: PanelKind[] = [
'signoz/TimeSeriesPanel',
'signoz/BarChartPanel',
'signoz/NumberPanel',
'signoz/HistogramPanel',
'signoz/PieChartPanel',
'signoz/TablePanel',
];
it.each(AI_KINDS)('is offered by %s, for traces only', (kind) => {
expect(isQueryModeSupportedByPanelKind(kind, AI_QUERY_MODE)).toBe(true);
expect(getSupportedSignals(kind, AI_QUERY_MODE)).toStrictEqual([traces]);
});
it('is not offered by List, whose raw rows carry no aggregation', () => {
expect(
isQueryModeSupportedByPanelKind('signoz/ListPanel', AI_QUERY_MODE),
).toBe(false);
});
it('does not leak into the legacy queryType axis', () => {
expect(getSupportedQueryTypes('signoz/TimeSeriesPanel')).not.toContain(
AI_QUERY_MODE,
);
});
it('leaves the kind-wide signal list unchanged', () => {
// traces is already in the builder mode's list, so the union must not repeat it.
expect(getSupportedSignals('signoz/TimeSeriesPanel')).toStrictEqual([
metrics,
logs,
traces,
]);
});
it('pairs with traces but not with the other signals of the kind', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/TimeSeriesPanel',
mode: AI_QUERY_MODE,
signal: traces,
}),
).toBe(true);
expect(
isPanelCombinationValid({
kind: 'signoz/TimeSeriesPanel',
mode: AI_QUERY_MODE,
signal: logs,
}),
).toBe(false);
});
});
describe('resolveQueryMode', () => {
it('keeps the AI mode on a kind that offers it', () => {
expect(
resolveQueryMode('signoz/TimeSeriesPanel', AI_QUERY_MODE, traces),
).toBe(AI_QUERY_MODE);
});
it('coerces the AI mode on a kind that does not offer it', () => {
expect(resolveQueryMode('signoz/ListPanel', AI_QUERY_MODE, traces)).toBe(
QUERY_BUILDER,
);
});
it('coerces the AI mode when the signal moved off traces', () => {
expect(resolveQueryMode('signoz/TimeSeriesPanel', AI_QUERY_MODE, logs)).toBe(
QUERY_BUILDER,
);
});
it('leaves a query-language mode alone', () => {
expect(resolveQueryMode('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(resolveQueryMode('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
});
});
describe('getHiddenQueryBuilderFields', () => {
it('returns {} for kinds that declare no field rules', () => {
expect(

View File

@@ -8,10 +8,16 @@ import {
} from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
import {
listQueryModes,
listQueryTypes,
signalsForMode,
type PanelQueryMode,
} from './types/queryModes';
/**
* The single deterministic guard for V2 dashboards. Every "what works with what"
* question — panel kind × query type × signal, and which query-builder fields a kind
* question — panel kind × query mode × signal, and which query-builder fields a kind
* hides — is answered here by reading each kind's declared capabilities from the panel
* registry. Adding a new kind means declaring its capabilities once in its definition;
* these functions then cover it automatically. Pure and side-effect free.
@@ -52,23 +58,47 @@ export function requireQueryPanelDefinition(
return definition;
}
/** Signals a kind can visualize. */
/** Every mode this kind offers, in declaration order (the builder first). */
export function getSupportedQueryModes(kind: PanelKind): PanelQueryMode[] {
const definition = getQueryPanelDefinition(kind);
return definition ? listQueryModes(definition.supportedQueryModes) : [];
}
export function isQueryModeSupportedByPanelKind(
kind: PanelKind,
mode: PanelQueryMode,
): boolean {
return getSupportedQueryModes(kind).includes(mode);
}
/**
* Signals a kind can visualize — in `mode` when one is given, else across every mode it
* offers. The mode-scoped form is what keeps the two axes interoperable: the AI mode
* authors traces only, on a kind whose builder mode also takes logs and metrics.
*/
export function getSupportedSignals(
kind: PanelKind,
mode?: PanelQueryMode,
): TelemetrytypesSignalDTO[] {
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
const modes = getQueryPanelDefinition(kind)?.supportedQueryModes;
return modes ? signalsForMode(modes, mode) : [];
}
export function isSignalSupported(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
mode?: PanelQueryMode,
): boolean {
return getSupportedSignals(kind).includes(signal);
return getSupportedSignals(kind, mode).includes(signal);
}
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
/**
* Query languages a kind supports (Query Builder / ClickHouse / PromQL) — its modes
* minus the AI one, for the call sites that speak the legacy `queryType` axis.
*/
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
const definition = getQueryPanelDefinition(kind);
return definition ? listQueryTypes(definition.supportedQueryModes) : [];
}
export function isQueryTypeSupportedByPanelKind(
@@ -79,48 +109,63 @@ export function isQueryTypeSupportedByPanelKind(
}
/**
* 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
* validated only when one is given.
* Master guard: is this panel kind renderable in this mode (and, where the mode carries
* a signal, with this signal)? ClickHouse/PromQL queries carry no signal, so the signal
* is validated only when one is given.
*/
export function isPanelCombinationValid({
kind,
queryType,
mode,
signal,
}: {
kind: PanelKind;
queryType: EQueryType;
mode: PanelQueryMode;
signal?: TelemetrytypesSignalDTO;
}): boolean {
// A query-less kind ignores the query entirely, so it pairs with anything.
if (isStaticPanelKind(kind)) {
return true;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
if (!isQueryModeSupportedByPanelKind(kind, mode)) {
return false;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {
if (signal !== undefined && !isSignalSupported(kind, signal, mode)) {
return false;
}
return true;
}
/**
* The query type to use for a kind given a `preferred` one: keep it if the kind
* supports it, otherwise fall back to the kind's first supported type. Used when
* switching panel kinds to coerce an unsupported active query type (e.g. PromQL → a
* List panel coerces to Query Builder).
* The mode to use for a kind given a `preferred` one: keep it if the kind offers it and
* it admits the signal, otherwise fall back to the kind's first mode. Used when switching
* panel kinds to coerce an unsupported active mode (PromQL → a List panel coerces to Query
* Builder; AI → a kind with no AI mode does the same).
*/
export function resolveQueryMode(
kind: PanelKind,
preferred: PanelQueryMode,
signal?: TelemetrytypesSignalDTO,
): PanelQueryMode {
const supported = getSupportedQueryModes(kind);
if (
supported.includes(preferred) &&
(signal === undefined || isSignalSupported(kind, signal, preferred))
) {
return preferred;
}
// A query-less kind has no modes; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
}
/** `resolveQueryMode` narrowed to the legacy `queryType` axis. */
export function resolveQueryType(
kind: PanelKind,
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
return supported.includes(preferred)
? preferred
: (supported[0] ?? EQueryType.QUERY_BUILDER);
}
/**

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[EQueryType.PROM]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
// Bars are binned client-side from a raw time series, so the request asks for a
// step interval wide enough to keep the bar count readable (V1 parity).

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[EQueryType.PROM]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
// Buckets are computed client-side from the raw series, so the request is a plain
// time series — the bucket count is a display concern, not a query one.

View File

@@ -18,16 +18,18 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
icon: List,
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
// Raw records come from logs and traces; metrics don't produce row data. No AI mode:
// the AI builder authors aggregations, which raw rows have no place for.
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.logs, TelemetrytypesSignalDTO.traces],
},
},
// Raw rows have no aggregation, so step interval / having never apply, and the
// Where clause searches the log/span body via `body CONTAINS`. Traces additionally
// 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],
queryBuilderFields: {
default: {
stepInterval: { isHidden: true, isDisabled: true },

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[EQueryType.PROM]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
@@ -18,12 +20,21 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
@@ -18,12 +20,21 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
queryCapabilities: {

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[EQueryType.PROM]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

@@ -25,8 +25,7 @@ export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections: [],
supportedSignals: [],
supportedQueryTypes: [],
supportedQueryModes: {},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

@@ -4,7 +4,6 @@ import {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ChartLine } from '@signozhq/icons';
import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
@@ -13,6 +12,7 @@ import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type { SupportedQueryModes } from './queryModes';
import type {
BaseRendererProps,
PanelRendererProps,
@@ -114,10 +114,8 @@ export interface QueryPanelDefinition<
Renderer: ComponentType<PanelRendererProps<K>>;
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
EditorPane: ComponentType<QueryEditorPaneProps>;
/** Signals this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
supportedQueryTypes: EQueryType[];
/** Modes this kind offers, each with the signals authorable in it. */
supportedQueryModes: SupportedQueryModes;
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
queryBuilderFields: QueryBuilderFieldRule;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */

View File

@@ -0,0 +1,66 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { QueryType } from 'types/api/v5/queryRange';
import type { EQueryType } from 'types/common/dashboard';
/**
* The query-authoring modes a panel kind can offer, one per editor tab. Three are query
* languages (`EQueryType`); the fourth is the AI query builder, which is a builder query
* under the hood and so has no `EQueryType` of its own — it is keyed by the v5 query type
* stamped on each of its queries (`IBuilderQuery.builderQueryType`), which is what lets
* the active tab be read straight off the query.
*/
export type PanelQueryMode =
| EQueryType
| Extract<QueryType, 'builder_ai_query'>;
/** The AI query builder's mode key. */
export const AI_QUERY_MODE = 'builder_ai_query' as const;
/**
* What a mode authors: a set of signals, or nothing signal-shaped at all. ClickHouse and
* PromQL are raw query text and carry no signal, which is a different statement from "this
* mode takes an empty set of signals" — the union makes the two unconfusable.
*/
export type QueryModeCapability =
| { kind: 'signal'; signals: TelemetrytypesSignalDTO[] }
| { kind: 'signal-less' };
/**
* Every mode a kind offers, with what each one authors. Partial on purpose: an absent key
* means the kind does not offer that mode at all (no tab, and the kind is disabled while that
* mode is active), which is how List opts out of everything but the builder.
*
* Declaring modes and signals together is what keeps them interoperable: AI is traces-only on
* a kind whose builder mode also takes logs and metrics — which two independent lists can't
* express.
*/
export type SupportedQueryModes = Partial<
Record<PanelQueryMode, QueryModeCapability>
>;
/** Declaration order, which puts the builder first. */
export function listQueryModes(modes: SupportedQueryModes): PanelQueryMode[] {
return Object.keys(modes) as PanelQueryMode[];
}
/** The modes that are query languages, for call sites on the legacy `queryType` axis. */
export function listQueryTypes(modes: SupportedQueryModes): EQueryType[] {
return listQueryModes(modes).filter(
(mode): mode is EQueryType => mode !== AI_QUERY_MODE,
);
}
/** Signals authorable in `mode`, or across every mode when none is given. */
export function signalsForMode(
modes: SupportedQueryModes,
mode?: PanelQueryMode,
): TelemetrytypesSignalDTO[] {
if (mode) {
const capability = modes[mode];
return capability?.kind === 'signal' ? capability.signals : [];
}
const all = Object.values(modes).flatMap((capability) =>
capability?.kind === 'signal' ? capability.signals : [],
);
return [...new Set(all)];
}

View File

@@ -4,6 +4,11 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import {
isBuilderEnvelope,
isBuilderPluginKind,
} from '../../queryV5/builderEnvelope';
/**
* Flattens a panel's queries into its builder queries, unwrapping
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
@@ -16,13 +21,13 @@ export function getBuilderQueries(
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope.spec.plugin;
if (plugin.kind === 'signoz/BuilderQuery') {
if (isBuilderPluginKind(plugin.kind)) {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries || []).forEach((sub) => {
if (sub.type === 'builder_query') {
if (isBuilderEnvelope(sub)) {
flattened.push(sub.spec as BuilderQuery);
}
});

View File

@@ -2,7 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getQueryPanelDefinition } from '../capabilities';
import { getQueryPanelDefinition, getSupportedSignals } from '../capabilities';
import { toPanelType } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
@@ -22,7 +22,7 @@ export function getPanelBuilderQuery(
if (!definition) {
return null;
}
const [defaultSignal] = definition.supportedSignals;
const [defaultSignal] = getSupportedSignals(kind);
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
// metrics default isn't authorable in every kind (e.g. List).
if (panel.spec.queries.length === 0 && defaultSignal) {

View File

@@ -0,0 +1,65 @@
import { initialQueryAIWithType } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { AI_QUERY_MODE, type PanelQueryMode } from '../types/queryModes';
/**
* The mode a query is authored in. The AI mode has no `queryType` of its own — it is a
* builder query tagged per-query — so it is read off the queries rather than off the
* envelope around them.
*/
export function getQueryMode(query: Query): PanelQueryMode {
if (
query.queryType === EQueryType.QUERY_BUILDER &&
(query.builder?.queryData ?? []).some(
(q) => q.builderQueryType === AI_QUERY_MODE,
)
) {
return AI_QUERY_MODE;
}
return query.queryType;
}
/**
* Retargets a query at `mode`, which is the only safe way to change it: the AI tag lives on
* every query and has to be stamped or cleared in step with `queryType`, or the tab and the
* queries disagree.
*
* Entering the AI mode keeps queries that are already traces (the common case — the user was
* building a trace query and wants AI's field set); anything else is replaced with a fresh AI
* query rather than coerced, which would leave metric aggregations on a traces query.
*/
export function withQueryMode(query: Query, mode: PanelQueryMode): Query {
if (mode === AI_QUERY_MODE) {
const queryData = query.builder?.queryData ?? [];
const isAllTraces =
queryData.length > 0 &&
queryData.every((q) => q.dataSource === DataSource.TRACES);
return {
...query,
queryType: EQueryType.QUERY_BUILDER,
builder: isAllTraces
? {
...query.builder,
queryData: queryData.map((q) => ({
...q,
builderQueryType: AI_QUERY_MODE,
})),
}
: initialQueryAIWithType.builder,
};
}
return {
...query,
queryType: mode,
builder: {
...query.builder,
queryData: (query.builder?.queryData ?? []).map((q) => ({
...q,
builderQueryType: undefined,
})),
},
};
}

View File

@@ -28,12 +28,17 @@ import { useViewPanel } from '../hooks/useViewPanel';
import { buildMoveItems } from '../utils/buildMoveItems';
import MenuActionItem from '../../../components/MenuActionItem/MenuActionItem';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { isAIBuilderEnvelope } from '../../../queryV5/builderEnvelope';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
const EMPTY_SECTIONS: DashboardSection[] = [];
const ALERT_FROM_AI_PANEL_REASON =
'Alerts are not available for AI Query Builder panels';
interface UsePanelActionItemsArgs {
panelId: string;
/** The panel itself — seeds "Create Alerts" and the download filename. */
@@ -63,6 +68,10 @@ export function usePanelActionItems({
panelActions,
}: UsePanelActionItemsArgs): PanelActionItems {
const panelKind = panel.spec.plugin.kind;
// The alert builder has no AI query mode, so the flow would open on an empty query.
const isAIPanel = toQueryEnvelopes(panel.spec.queries).some(
isAIBuilderEnvelope,
);
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const openPanelEditor = useOpenPanelEditor();
@@ -157,7 +166,15 @@ export function usePanelActionItems({
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
label: row('Create Alerts', <Bell size={14} />, { checks: [] }),
label: (
<MenuActionItem
label="Create Alerts"
icon={<Bell size={14} />}
checks={[]}
disabledTooltip={isAIPanel ? ALERT_FROM_AI_PANEL_REASON : undefined}
/>
),
disabled: isAIPanel,
onClick: (): void => createAlert(panel, panelId),
});
}
@@ -197,6 +214,7 @@ export function usePanelActionItems({
editChecks,
editDisabledTooltip,
panelCapabilities,
isAIPanel,
panel,
panelActions,
sections,

View File

@@ -60,7 +60,7 @@ function QueryViewModalBody({
setSpec,
panelDefinition,
signal,
queryType,
queryMode,
query,
runQuery,
resetQuery,
@@ -152,7 +152,7 @@ function QueryViewModalBody({
}}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
queryMode={queryMode}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}

View File

@@ -9,6 +9,7 @@ import type {
} from 'container/TopNav/DateTimeSelectionV2/types';
import { usePanelTypeSelectItems } from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
import ConfigSelect from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
import type { PanelQueryMode } from '../../../Panels/types/queryModes';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EQueryType } from 'types/common/dashboard';
@@ -41,7 +42,7 @@ interface QueryViewModalHeaderProps extends ViewPanelModalHeaderBaseProps {
* selector greys out kinds that can't be authored in it — e.g. List is
* Query-Builder-only, so PromQL/ClickHouse disable it.
*/
queryType: EQueryType;
queryMode: PanelQueryMode;
/** Current builder datasource — greys out kinds that don't support it (e.g. List needs logs/traces, not metrics). */
signal: TelemetrytypesSignalDTO;
/** Restore the saved query + kind (drilldown reset). */
@@ -74,7 +75,7 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({
queryType: query?.queryType ?? EQueryType.QUERY_BUILDER,
mode: query?.queryMode ?? EQueryType.QUERY_BUILDER,
signal: query?.signal,
});

View File

@@ -13,12 +13,13 @@ import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/Dashboa
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { getQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/queryMode';
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import {
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { EQueryType } from 'types/common/dashboard';
import type { PanelQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/types/queryModes';
interface UseViewPanelModeArgs {
panel: DashboardtypesPanelDTO;
@@ -43,7 +44,7 @@ export interface UseViewPanelModeReturn {
*/
signal: TelemetrytypesSignalDTO;
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
queryType: EQueryType;
queryMode: PanelQueryMode;
/** Query result for the draft over the per-view window. */
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
@@ -132,7 +133,7 @@ export function useViewPanelMode({
setSpec,
panelDefinition,
signal,
queryType: currentQuery.queryType,
queryMode: getQueryMode(currentQuery),
query,
runQuery,
resetQuery,

View File

@@ -46,6 +46,10 @@ beforeAll(() => {
});
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => false,
}));
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')

View File

@@ -16,6 +16,10 @@ import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => false,
}));
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')

View File

@@ -104,6 +104,23 @@ describe('toQueryEnvelopes', () => {
);
});
it('wraps a bare AI builder plugin as a builder_ai_query envelope', () => {
const ai = [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/AIBuilderQuery',
spec: { name: 'A', signal: 'traces' },
},
},
},
] as unknown as DashboardtypesQueryDTO[];
expect(toQueryEnvelopes(ai)).toStrictEqual([
{ type: 'builder_ai_query', spec: { name: 'A', signal: 'traces' } },
]);
});
it('wraps PromQL and ClickHouse plugins with their envelope types', () => {
const prom = [
{

View File

@@ -0,0 +1,58 @@
import type { Querybuildertypesv5QueryEnvelopeDTO } from 'api/generated/services/sigNoz.schemas';
import {
isAIBuilderEnvelope,
isBuilderEnvelope,
isBuilderPluginKind,
} from '../builderEnvelope';
// Only `type` is read; the generated envelope union erases spec to unknown anyway.
const envelope = (type: string): Querybuildertypesv5QueryEnvelopeDTO =>
({ type, spec: {} }) as unknown as Querybuildertypesv5QueryEnvelopeDTO;
describe('builder envelope predicates', () => {
describe('isBuilderEnvelope', () => {
it.each(['builder_query', 'builder_ai_query'])(
'accepts %s — both carry a builder query spec',
(type) => {
expect(isBuilderEnvelope(envelope(type))).toBe(true);
},
);
// Formula and TraceOperator carry no signal and reference other queries by name.
it.each([
'builder_formula',
'builder_trace_operator',
'promql',
'clickhouse_sql',
])('rejects %s', (type) => {
expect(isBuilderEnvelope(envelope(type))).toBe(false);
});
});
describe('isAIBuilderEnvelope', () => {
it('accepts builder_ai_query', () => {
expect(isAIBuilderEnvelope(envelope('builder_ai_query'))).toBe(true);
});
it('rejects builder_query, which is the whole point of the narrower check', () => {
expect(isAIBuilderEnvelope(envelope('builder_query'))).toBe(false);
});
});
describe('isBuilderPluginKind', () => {
it.each(['signoz/BuilderQuery', 'signoz/AIBuilderQuery'])(
'accepts %s — both wrap a builder query spec directly',
(kind) => {
expect(isBuilderPluginKind(kind)).toBe(true);
},
);
it.each(['signoz/CompositeQuery', 'signoz/PromQLQuery'])(
'rejects %s',
(kind) => {
expect(isBuilderPluginKind(kind)).toBe(false);
},
);
});
});

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';
@@ -193,6 +197,24 @@ describe('persesQueryAdapters', () => {
);
});
it('emits a bare signoz/AIBuilderQuery for an AI List panel', () => {
const result = toPerses(initialQueryAIWithType, PANEL_TYPES.LIST);
expect(result).toHaveLength(1);
expect(result[0].spec.plugin.kind).toBe('signoz/AIBuilderQuery');
});
// List rejects CompositeQuery backend-side, so the plugin kind is the only place
// the AI-ness can survive a save.
it('preserves an AI List query through toPerses → fromPerses', () => {
const perses = toPerses(initialQueryAIWithType, PANEL_TYPES.LIST);
const restored = fromPerses(perses, PANEL_TYPES.LIST);
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

@@ -11,12 +11,14 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5OrderDirectionDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { isBuilderEnvelope } from './builderEnvelope';
// Narrow view over the envelope spec variants. Orval erases envelope `spec` to `unknown`, so
// shared fields are read through this view with a localized cast at the envelope boundary.
@@ -58,6 +60,14 @@ export function toQueryEnvelopes(
spec: plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO,
},
];
case 'signoz/AIBuilderQuery':
// Same wire shape; the widening is only orval's separate `signal` enum, which TS treats nominally.
return [
{
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
spec: plugin.spec,
} as unknown as Querybuildertypesv5QueryEnvelopeDTO,
];
case 'signoz/PromQLQuery':
return [
{
@@ -125,13 +135,10 @@ function withBarStepInterval(
): Querybuildertypesv5QueryEnvelopeDTO[] {
const stepInterval = getBarStepIntervalSeconds(startMs, endMs);
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
if (envelope.spec?.stepInterval) {
if ((envelope.spec as QuerySpecView | undefined)?.stepInterval) {
return envelope;
}
return {
@@ -139,8 +146,8 @@ function withBarStepInterval(
spec: {
...envelope.spec,
stepInterval,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
},
} as Querybuildertypesv5QueryEnvelopeDTO;
});
}
@@ -153,10 +160,7 @@ function withListOrderTiebreaker(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
const spec = envelope.spec as QuerySpecView;
@@ -181,8 +185,8 @@ function withListOrderTiebreaker(
...primary,
{ key: { name: 'id' }, direction: primary[0].direction },
],
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
},
} as Querybuildertypesv5QueryEnvelopeDTO;
});
}
@@ -195,10 +199,7 @@ function withPagination(
{ offset, limit }: { offset: number; limit: number },
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
return {
@@ -207,8 +208,8 @@ function withPagination(
...envelope.spec,
offset,
limit,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
},
} as Querybuildertypesv5QueryEnvelopeDTO;
});
}
@@ -301,11 +302,7 @@ export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
}
const metricsSpecs = envelopes
.filter(
(envelope) =>
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
)
.filter(isBuilderEnvelope)
.map((envelope) => envelope.spec as QuerySpecView)
.filter((spec) => spec.signal === 'metrics');

View File

@@ -0,0 +1,35 @@
import type { Querybuildertypesv5QueryEnvelopeDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesQueryPluginKindDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
} from 'api/generated/services/sigNoz.schemas';
const BUILDER_ENVELOPE_TYPES: string[] = [
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
];
export function isBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean {
return BUILDER_ENVELOPE_TYPES.includes(envelope.type);
}
export function isAIBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean {
return (
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query
);
}
const BUILDER_PLUGIN_KINDS: string[] = [
DashboardtypesQueryPluginKindDTO['signoz/BuilderQuery'],
DashboardtypesQueryPluginKindDTO['signoz/AIBuilderQuery'],
];
export function isBuilderPluginKind(kind: string): boolean {
return BUILDER_PLUGIN_KINDS.includes(kind);
}

View File

@@ -1,13 +1,14 @@
import type {
DashboardtypesAIBuilderQuerySpecDTO,
DashboardtypesBuilderQuerySpecDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind as AIBuilderQueryPluginKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryPluginKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryPluginKind,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
@@ -21,6 +22,7 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { isAIBuilderEnvelope, isBuilderEnvelope } from './builderEnvelope';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
/**
@@ -44,11 +46,6 @@ const toGeneratedEnvelopes = (
): Querybuildertypesv5QueryEnvelopeDTO[] =>
envelopes as unknown as Querybuildertypesv5QueryEnvelopeDTO[];
const isBuilderQueryEnvelope = (
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean =>
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
/**
* Clears the V1 explorer's `pageSize`/`offset` before conversion — the shared mapper folds
* `pageSize` into the V5 `limit`, which usePanelQuery would read as a user cap and hide the
@@ -155,8 +152,9 @@ export function fromPerses(
/**
* V1 `Query` → perses panel queries (to write the builder result back to the editor
* draft). Wrapped in a single `signoz/CompositeQuery` to satisfy the
* `panel.queries.length === 1` invariant. Exception: List emits its one builder query
* as a bare `signoz/BuilderQuery` because the backend rejects a `signoz/CompositeQuery`.
* `panel.queries.length === 1` invariant. Exception: List rejects `signoz/CompositeQuery`
* backend-side, so it emits its one builder query as the bare plugin matching the query's
* own kind — a bare plugin carries no envelope `type`, so the kind is what preserves it.
*/
export function toPerses(
query: Query,
@@ -170,21 +168,24 @@ export function toPerses(
const envelopes = toGeneratedEnvelopes(composite.queries ?? []);
if (panelType === PANEL_TYPES.LIST) {
const builder = envelopes.find(isBuilderQueryEnvelope);
const builder = envelopes.find(isBuilderEnvelope);
if (!builder) {
return [];
}
// Envelope `spec` is undiscriminated, so narrow it to the spec its plugin kind declares.
const plugin = isAIBuilderEnvelope(builder)
? {
kind: AIBuilderQueryPluginKind['signoz/AIBuilderQuery'],
spec: builder.spec as unknown as DashboardtypesAIBuilderQuerySpecDTO,
}
: {
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
};
return [
{
kind: panelTypeToRequestType(panelType),
spec: {
plugin: {
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
// The generated envelope union doesn't discriminate `spec` by `type`, so
// narrow the filtered builder query to the dashboard builder spec.
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
},
},
spec: { plugin },
},
];
}

View File

@@ -3,10 +3,9 @@ import type {
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
import { isBuilderEnvelope } from './builderEnvelope';
import type { PanelTable, PanelTableColumn } from './types';
@@ -28,13 +27,12 @@ export function extractAggregationsPerQuery(
): AggregationsPerQuery {
const perQuery: AggregationsPerQuery = {};
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return;
}
const spec = envelope.spec;
const spec = envelope.spec as
| { name?: string; aggregations?: unknown }
| undefined;
if (spec?.name && spec.aggregations) {
perQuery[spec.name] = spec.aggregations as AggregationView[];
}

View File

@@ -1,7 +1,7 @@
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { isQueryTypeSupportedByPanelKind } from '../DashboardContainer/Panels/capabilities';
import { isQueryModeSupportedByPanelKind } from '../DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
@@ -22,7 +22,7 @@ jest.mock('../DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/capabilities', () => ({
isQueryTypeSupportedByPanelKind: jest.fn(),
isQueryModeSupportedByPanelKind: jest.fn(),
// Real predicate: these specs exercise query kinds; the static guard has its
// own case below.
isStaticPanelKind: jest.requireActual(
@@ -34,7 +34,7 @@ const mockToPerses = toPerses as jest.Mock;
const mockBuildDefaultQueries = buildDefaultQueries as jest.Mock;
const mockBuildPluginSpec = buildPluginSpec as jest.Mock;
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
const mockIsQueryTypeSupported = isQueryTypeSupportedByPanelKind as jest.Mock;
const mockIsQueryModeSupported = isQueryModeSupportedByPanelKind as jest.Mock;
const DEFAULT_QUERIES = [{ kind: 'default' }];
const CONVERTED_QUERIES = [{ kind: 'converted' }];
@@ -54,7 +54,7 @@ describe('buildNewPanelSeed', () => {
mockBuildDefaultQueries.mockReturnValue(DEFAULT_QUERIES);
mockBuildPluginSpec.mockReturnValue(BASE_SPEC);
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
mockIsQueryTypeSupported.mockReturnValue(true);
mockIsQueryModeSupported.mockReturnValue(true);
});
it('uses the kind default seed when it is not an explorer export', () => {
@@ -77,7 +77,7 @@ describe('buildNewPanelSeed', () => {
});
it('coerces a builder-only kind to Table for a ClickHouse query', () => {
mockIsQueryTypeSupported.mockReturnValue(false);
mockIsQueryModeSupported.mockReturnValue(false);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed(
@@ -91,7 +91,7 @@ describe('buildNewPanelSeed', () => {
});
it('coerces a builder-only kind to TimeSeries for a PromQL query', () => {
mockIsQueryTypeSupported.mockReturnValue(false);
mockIsQueryModeSupported.mockReturnValue(false);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed(

View File

@@ -4,13 +4,14 @@ import { EQueryType } from 'types/common/dashboard';
import {
isStaticPanelKind,
isQueryTypeSupportedByPanelKind,
isQueryModeSupportedByPanelKind,
} from '../DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { toPanelType } from '../DashboardContainer/Panels/types/panelKind';
import type { PanelKind } from '../DashboardContainer/Panels/types/panelKind';
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import { getQueryMode } from '../DashboardContainer/Panels/utils/queryMode';
import {
buildPluginSpec,
type SeededPluginSpec,
@@ -36,7 +37,9 @@ function resolveSeededPanelKind(
requestedKind: PanelKind,
compositeQuery: Query,
): PanelKind {
if (isQueryTypeSupportedByPanelKind(requestedKind, compositeQuery.queryType)) {
if (
isQueryModeSupportedByPanelKind(requestedKind, getQueryMode(compositeQuery))
) {
return requestedKind;
}
return (

View File

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

View File

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

View File

@@ -157,6 +157,7 @@ func (d *DashboardSpec) validateQuery(qi int, q Query, panelKind PanelPluginKind
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeBuilderAI: QueryKindAIBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,

View File

@@ -117,6 +117,22 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
// An AI builder query is always a traces query, so it counts towards traces.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
aiBuilder := `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
}`
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
}
// promql and clickhouse queries carry no signal, so they land in the panel total
// and nowhere else.
func TestNewStatsFromStorableDashboardsIgnoresSignallessQueries(t *testing.T) {

View File

@@ -8,6 +8,7 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
@@ -1618,6 +1619,43 @@ func TestStorageRoundTrip(t *testing.T) {
assert.Contains(t, responseStr, `"operator":"above"`, "expected operator:above after storage round-trip")
}
// An AI builder query carries no signal of its own: the plugin kind implies
// gen_ai, which only reads traces, so decode pins the signal and marshal emits it.
func TestAIBuilderQueryStorageRoundTrip(t *testing.T) {
input := []byte(`{
"variables": [],
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {
"name": "A", "aggregations": [{"expression": "count()"}]
}}}}]
}}},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(input)
require.NoError(t, err)
plugin := d.Panels["p1"].Spec.Queries[0].Spec.Plugin
assert.Equal(t, QueryKindAIBuilder, plugin.Kind)
aiSpec, ok := plugin.Spec.(*AIBuilderQuerySpec)
require.True(t, ok, "expected *AIBuilderQuerySpec, got %T", plugin.Spec)
assert.Equal(t, "A", aiSpec.Name)
assert.Equal(t, telemetrytypes.SignalTraces, aiSpec.Signal)
stored, err := json.Marshal(plugin)
require.NoError(t, err)
assert.Contains(t, string(stored), `"kind":"signoz/AIBuilderQuery"`)
assert.Contains(t, string(stored), `"signal":"traces"`)
var loaded QueryPlugin
require.NoError(t, json.Unmarshal(stored, &loaded))
assert.Equal(t, plugin, loaded)
}
func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
const validSpec = `"spec": {"variables": [], "panels": {}, "layouts": [], "links": []}`
@@ -1830,6 +1868,8 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
{"TimeSeries+PromQL", mkQuery("signoz/TimeSeriesPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), false},
{"Table+ClickHouse", mkQuery("signoz/TablePanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
// Top-level: rejected
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
@@ -1839,6 +1879,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
// Composite sub-queries
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
}
for _, tc := range cases {

View File

@@ -91,6 +91,7 @@ type QueryPlugin struct {
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(QueryKindBuilder): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec"),
string(QueryKindAIBuilder): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec"),
string(QueryKindComposite): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery"),
string(QueryKindFormula): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula"),
string(QueryKindPromQL): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery"),
@@ -120,6 +121,7 @@ func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
func (QueryPlugin) JSONSchemaOneOf() []any {
return []any{
QueryPluginVariant[BuilderQuerySpec]{Kind: string(QueryKindBuilder)},
QueryPluginVariant[AIBuilderQuerySpec]{Kind: string(QueryKindAIBuilder)},
QueryPluginVariant[CompositeQuerySpec]{Kind: string(QueryKindComposite)},
QueryPluginVariant[FormulaSpec]{Kind: string(QueryKindFormula)},
QueryPluginVariant[PromQLQuerySpec]{Kind: string(QueryKindPromQL)},
@@ -140,6 +142,11 @@ func (plugin QueryPlugin) buildV5CompositeQueryFromPlugin() (qb.CompositeQuery,
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "builder query is empty")
}
return wrapEnvelope(qb.QueryTypeBuilder, spec.Spec), nil
case *AIBuilderQuerySpec:
if spec == nil {
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "AI builder query is empty")
}
return wrapEnvelope(qb.QueryTypeBuilderAI, qb.QueryBuilderQuery[qb.TraceAggregation](*spec)), nil
case *qb.PromQuery:
return wrapEnvelope(qb.QueryTypePromQL, *spec), nil
case *qb.ClickHouseQuery:
@@ -234,6 +241,7 @@ var (
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindAIBuilder: func() any { return new(AIBuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
@@ -246,13 +254,13 @@ var (
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
PanelKindText: {},
}
)

View File

@@ -106,6 +106,13 @@ func redactQuery(spec any) any {
return spec
}
return &BuilderQuerySpec{Spec: redactLeafQuery(s.Spec)}
case *AIBuilderQuerySpec:
if s == nil {
return spec
}
redacted := redactLeafQuery(qb.QueryBuilderQuery[qb.TraceAggregation](*s)).(qb.QueryBuilderQuery[qb.TraceAggregation])
out := AIBuilderQuerySpec(redacted)
return &out
case *qb.PromQuery:
return redactQueryPtr(s)
case *qb.ClickHouseQuery:

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -159,6 +160,11 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
plugin QueryPlugin
expectedType qb.QueryType
}{
{
description: "AI builder query",
plugin: QueryPlugin{Kind: QueryKindAIBuilder, Spec: &AIBuilderQuerySpec{Name: "A"}},
expectedType: qb.QueryTypeBuilderAI,
},
{
description: "promql",
plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &qb.PromQuery{Name: "A", Query: "up"}},
@@ -209,6 +215,42 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
}
})
// The gen_ai statement builder only reads traces, so an AI builder query
// carries no signal of its own and unwraps to a traces builder query.
t.Run("unwraps an AI builder query to a traces builder query", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
Queries: []Query{
{
Kind: qb.RequestTypeTimeSeries,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindAIBuilder,
Spec: &AIBuilderQuerySpec{Name: "A", Signal: telemetrytypes.SignalTraces},
},
},
},
},
},
},
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.Len(t, req.CompositeQuery.Queries, 1)
spec, ok := req.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
require.True(t, ok, "expected traces builder query, got %T", req.CompositeQuery.Queries[0].Spec)
assert.Equal(t, "A", spec.Name)
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
})
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{

View File

@@ -133,6 +133,19 @@ func TestRedactQueryPluginWrappers(t *testing.T) {
assert.Equal(t, "A", builder.Name)
})
t.Run("AI builder plugin pointer is redacted and stays a pointer", func(t *testing.T) {
plugin := &AIBuilderQuerySpec{
Name: "A",
Filter: &qb.Filter{Expression: "body contains 'secret'"},
}
result, ok := redactQuery(plugin).(*AIBuilderQuerySpec)
require.True(t, ok)
assert.Nil(t, result.Filter)
assert.Equal(t, "A", result.Name)
})
t.Run("composite plugin redacts every sub-query envelope", func(t *testing.T) {
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A", Filter: &qb.Filter{Expression: "x = 1"}}},

View File

@@ -93,6 +93,7 @@ type QueryPluginKind string
const (
QueryKindBuilder QueryPluginKind = "signoz/BuilderQuery"
QueryKindAIBuilder QueryPluginKind = "signoz/AIBuilderQuery"
QueryKindComposite QueryPluginKind = "signoz/CompositeQuery"
QueryKindFormula QueryPluginKind = "signoz/Formula"
QueryKindPromQL QueryPluginKind = "signoz/PromQLQuery"
@@ -101,7 +102,7 @@ const (
)
func (QueryPluginKind) Enum() []any {
return []any{QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL, QueryKindTraceOperator}
return []any{QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL, QueryKindTraceOperator}
}
type (
@@ -159,6 +160,26 @@ func (BuilderQuerySpec) JSONSchemaOneOf() []any {
}
}
// AIBuilderQuerySpec is the spec of a signoz/AIBuilderQuery plugin: a gen_ai-scoped
// (AI observability) traces builder query, executed as qb.QueryTypeBuilderAI. The
// signal is implied by the kind and pinned to traces, mirroring the builder_ai_query
// QueryEnvelope decode.
type AIBuilderQuerySpec qb.QueryBuilderQuery[qb.TraceAggregation]
func (b *AIBuilderQuerySpec) UnmarshalJSON(data []byte) error {
var spec qb.QueryBuilderQuery[qb.TraceAggregation]
if err := json.Unmarshal(data, &spec); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid AI builder query spec")
}
spec.Signal = telemetrytypes.SignalTraces
*b = AIBuilderQuerySpec(spec)
return nil
}
func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
return (qb.QueryBuilderQuery[qb.TraceAggregation]{}).PrepareJSONSchema(s)
}
// ══════════════════════════════════════════════
// SigNoz panel plugin specs
// ══════════════════════════════════════════════