mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-20 18:30:41 +01:00
Compare commits
8 Commits
nv/dashboa
...
feat/ai-qu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49164911f4 | ||
|
|
98b8e223b6 | ||
|
|
7b3a0e038b | ||
|
|
2d48967690 | ||
|
|
c29b7b492f | ||
|
|
d49919053f | ||
|
|
e504c4081e | ||
|
|
67878a01b2 |
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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(
|
||||
|
||||
@@ -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'] = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
44
frontend/src/utils/__tests__/extraFields.test.ts
Normal file
44
frontend/src/utils/__tests__/extraFields.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
23
frontend/src/utils/extraFields.ts
Normal file
23
frontend/src/utils/extraFields.ts
Normal 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),
|
||||
),
|
||||
),
|
||||
];
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"}}},
|
||||
|
||||
@@ -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
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user