mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-20 18:30:41 +01:00
Compare commits
9 Commits
issue_6090
...
feat/ai-ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef1cf7cd14 | ||
|
|
49164911f4 | ||
|
|
98b8e223b6 | ||
|
|
9cfd5b3421 | ||
|
|
7b3a0e038b | ||
|
|
2d48967690 | ||
|
|
c29b7b492f | ||
|
|
d49919053f | ||
|
|
67878a01b2 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -67,7 +67,6 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -202,6 +202,7 @@ telemetrystore:
|
||||
max_bytes_to_read: 0
|
||||
max_result_rows: 0
|
||||
ignore_data_skipping_indices: ""
|
||||
secondary_indices_enable_bulk_filtering: false
|
||||
|
||||
##################### Prometheus #####################
|
||||
prometheus:
|
||||
|
||||
@@ -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:
|
||||
@@ -9439,29 +9517,6 @@ components:
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesGettableTraceSummary:
|
||||
properties:
|
||||
ai:
|
||||
$ref: '#/components/schemas/SpantypesTraceAISummary'
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -9745,32 +9800,6 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
tokens:
|
||||
$ref: '#/components/schemas/SpantypesTraceAITokens'
|
||||
totalCost:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
SpantypesTraceAITokens:
|
||||
properties:
|
||||
cacheRead:
|
||||
minimum: 0
|
||||
type: integer
|
||||
cacheWrite:
|
||||
minimum: 0
|
||||
type: integer
|
||||
input:
|
||||
minimum: 0
|
||||
type: integer
|
||||
output:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reasoning:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -15509,66 +15538,6 @@ paths:
|
||||
summary: Get aggregations for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v1/traces/{traceID}/summary:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the trace-level fields of the waterfall (time range, root,
|
||||
span counts, missing spans) and, when the trace has gen_ai spans, its token
|
||||
and cost totals. Computed in one aggregate query.
|
||||
operationId: GetTraceSummary
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get summary for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
|
||||
@@ -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',
|
||||
@@ -10884,78 +10963,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAITokensDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheRead?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheWrite?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
input?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
output?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAISummaryDTO {
|
||||
tokens?: SpantypesTraceAITokensDTO;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
totalCost?: number | null;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceSummaryDTO {
|
||||
ai?: SpantypesTraceAISummaryDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -12667,17 +12674,6 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceSummaryPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceSummary200 = {
|
||||
data: SpantypesGettableTraceSummaryDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -4,17 +4,11 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -22,8 +16,6 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -35,26 +27,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
@@ -155,108 +127,6 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the trace-level fields of the waterfall (time range, root, span counts, missing spans) and, when the trace has gen_ai spans, its token and cost totals. Computed in one aggregate query.
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const getTraceSummary = (
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceSummary200>({
|
||||
url: `/api/v1/traces/${traceID}/summary`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryKey = ({
|
||||
traceID,
|
||||
}: GetTraceSummaryPathParameters) => {
|
||||
return [`/api/v1/traces/${traceID}/summary`] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
|
||||
signal,
|
||||
}) => getTraceSummary({ traceID }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceSummaryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>
|
||||
>;
|
||||
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceSummary<
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const invalidateGetTraceSummary = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import type { PanelQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/types/queryModes';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
@@ -30,7 +30,7 @@ interface ConfigPaneProps {
|
||||
* panel types the visualization switcher disables — read from the provider, not the
|
||||
* spec, because a new panel's spec has no query until staged.
|
||||
*/
|
||||
queryType: EQueryType;
|
||||
mode: PanelQueryMode;
|
||||
/** Panel's resolved series, provided to sections that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
@@ -57,7 +57,7 @@ function ConfigPane({
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
mode,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
stepInterval,
|
||||
@@ -125,7 +125,7 @@ function ConfigPane({
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
mode={mode}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
@@ -149,7 +149,7 @@ function ConfigPane({
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
mode={mode}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import type { PanelQueryMode } from '../../../Panels/types/queryModes';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
@@ -11,8 +11,8 @@ import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
panelKind: PanelKind;
|
||||
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). */
|
||||
queryType: EQueryType;
|
||||
/** Active query mode — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse/AI disable it). */
|
||||
mode: PanelQueryMode;
|
||||
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
@@ -26,11 +26,11 @@ interface PanelTypeSwitcherProps {
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
queryType,
|
||||
mode,
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
const items = usePanelTypeSelectItems({ mode, signal });
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -43,18 +43,28 @@ describe('PanelTypeSwitcher', () => {
|
||||
jest.clearAllMocks();
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
mode: 'query',
|
||||
supportedSignals:
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => {
|
||||
const signals =
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
|
||||
: ['metrics', 'logs', 'traces'];
|
||||
const queryTypes = SUPPORTED_QUERY_TYPES[kind] ?? [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
}));
|
||||
];
|
||||
return {
|
||||
mode: 'query',
|
||||
supportedQueryModes: Object.fromEntries(
|
||||
queryTypes.map((queryType: EQueryType) => [
|
||||
queryType,
|
||||
queryType === EQueryType.QUERY_BUILDER
|
||||
? { kind: 'signal', signals }
|
||||
: { kind: 'signal-less' },
|
||||
]),
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
@@ -62,7 +72,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
mode={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -77,7 +87,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
mode={EQueryType.QUERY_BUILDER}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
@@ -93,7 +103,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
mode={EQueryType.QUERY_BUILDER}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -108,7 +118,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.PROM}
|
||||
mode={EQueryType.PROM}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -125,7 +135,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TablePanel"
|
||||
queryType={EQueryType.CLICKHOUSE}
|
||||
mode={EQueryType.CLICKHOUSE}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -11,14 +11,14 @@ describe('getPanelTypeDisabledReason', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
queryType: PROM,
|
||||
mode: PROM,
|
||||
label: 'Time Series',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
mode: QUERY_BUILDER,
|
||||
signal: logs,
|
||||
label: 'List',
|
||||
}),
|
||||
@@ -29,21 +29,21 @@ describe('getPanelTypeDisabledReason', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: PROM,
|
||||
mode: PROM,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List isn't available for PromQL queries");
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: CLICKHOUSE,
|
||||
mode: CLICKHOUSE,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List isn't available for ClickHouse queries");
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/TablePanel',
|
||||
queryType: PROM,
|
||||
mode: PROM,
|
||||
label: 'Table',
|
||||
}),
|
||||
).toBe("Table isn't available for PromQL queries");
|
||||
@@ -53,7 +53,7 @@ describe('getPanelTypeDisabledReason', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
mode: QUERY_BUILDER,
|
||||
signal: metrics,
|
||||
label: 'List',
|
||||
}),
|
||||
@@ -64,7 +64,7 @@ describe('getPanelTypeDisabledReason', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: PROM,
|
||||
mode: PROM,
|
||||
signal: metrics,
|
||||
label: 'List',
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { PANEL_OPTIONS } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import type { PanelQueryMode } from '../../../Panels/types/queryModes';
|
||||
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface UsePanelTypeSelectItemsArgs {
|
||||
/** Active query type — a kind that can't be authored in it is disabled. */
|
||||
queryType: EQueryType;
|
||||
/** Active query mode — a kind that can't be authored in it is disabled. */
|
||||
mode: PanelQueryMode;
|
||||
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ interface UsePanelTypeSelectItemsArgs {
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
mode,
|
||||
signal,
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
@@ -31,7 +31,7 @@ export function usePanelTypeSelectItems({
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind,
|
||||
queryType,
|
||||
mode,
|
||||
signal,
|
||||
label: displayName,
|
||||
});
|
||||
@@ -43,6 +43,6 @@ export function usePanelTypeSelectItems({
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
}),
|
||||
[queryType, signal],
|
||||
[mode, signal],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
isStaticPanelKind,
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isQueryModeSupportedByPanelKind,
|
||||
isSignalSupported,
|
||||
} from '../../../Panels/capabilities';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import {
|
||||
AI_QUERY_MODE,
|
||||
type PanelQueryMode,
|
||||
} from '../../../Panels/types/queryModes';
|
||||
|
||||
const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
|
||||
[EQueryType.QUERY_BUILDER]: 'Query Builder',
|
||||
@@ -14,6 +18,11 @@ const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
|
||||
[EQueryType.PROM]: 'PromQL',
|
||||
};
|
||||
|
||||
const MODE_LABEL: Record<PanelQueryMode, string> = {
|
||||
...QUERY_TYPE_LABEL,
|
||||
[AI_QUERY_MODE]: 'AI Query Builder',
|
||||
};
|
||||
|
||||
const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
|
||||
[TelemetrytypesSignalDTO.logs]: 'logs',
|
||||
[TelemetrytypesSignalDTO.traces]: 'traces',
|
||||
@@ -29,12 +38,12 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
|
||||
*/
|
||||
export function getPanelTypeDisabledReason({
|
||||
kind,
|
||||
queryType,
|
||||
mode,
|
||||
signal,
|
||||
label,
|
||||
}: {
|
||||
kind: PanelKind;
|
||||
queryType: EQueryType;
|
||||
mode: PanelQueryMode;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
label: string;
|
||||
}): string | undefined {
|
||||
@@ -44,10 +53,10 @@ export function getPanelTypeDisabledReason({
|
||||
if (isStaticPanelKind(kind)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
|
||||
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
|
||||
if (!isQueryModeSupportedByPanelKind(kind, mode)) {
|
||||
return `${label} isn't available for ${MODE_LABEL[mode]} queries`;
|
||||
}
|
||||
if (signal !== undefined && !isSignalSupported(kind, signal)) {
|
||||
if (signal !== undefined && !isSignalSupported(kind, signal, mode)) {
|
||||
return `${label} doesn't support ${SIGNAL_LABEL[signal]} data`;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -58,7 +58,7 @@ function SectionSlot({
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
mode,
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
@@ -124,7 +124,7 @@ function SectionSlot({
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
mode={mode}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
|
||||
@@ -49,7 +49,7 @@ function renderConfigPane(
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
mode: EQueryType.QUERY_BUILDER,
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelQueryMode } from '../../Panels/types/queryModes';
|
||||
import type { PanelKind } from '../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
/**
|
||||
* Context `SectionSlot` forwards to every section editor (not spec-slice fields — those
|
||||
@@ -17,7 +17,7 @@ export interface SectionEditorContext {
|
||||
panelKind?: PanelKind;
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
yAxisUnit?: string;
|
||||
queryType?: EQueryType;
|
||||
mode?: PanelQueryMode;
|
||||
stepInterval?: number;
|
||||
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
|
||||
metricUnit?: string;
|
||||
|
||||
@@ -16,7 +16,7 @@ import styles from './VisualizationSection.module.scss';
|
||||
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
|
||||
Pick<
|
||||
SectionEditorContext,
|
||||
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
|
||||
'panelKind' | 'onChangePanelKind' | 'signal' | 'mode'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ function VisualizationSection({
|
||||
onChange,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
mode,
|
||||
signal,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
return (
|
||||
@@ -39,9 +39,9 @@ function VisualizationSection({
|
||||
{controls.switchPanelKind && panelKind && onChangePanelKind && (
|
||||
<PanelTypeSwitcher
|
||||
panelKind={panelKind}
|
||||
// queryType is optional on the kind-erased section context, but always
|
||||
// mode is optional on the kind-erased section context, but always
|
||||
// supplied in practice; default to Query Builder at this boundary.
|
||||
queryType={queryType ?? EQueryType.QUERY_BUILDER}
|
||||
mode={mode ?? EQueryType.QUERY_BUILDER}
|
||||
signal={signal}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
|
||||
@@ -9,8 +9,11 @@ import VisualizationSection from '../VisualizationSection';
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(() => ({
|
||||
mode: 'query',
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
|
||||
supportedQueryModes: {
|
||||
builder: { kind: 'signal', signals: ['metrics', 'logs', 'traces'] },
|
||||
clickhouse_sql: { kind: 'signal-less' },
|
||||
promql: { kind: 'signal-less' },
|
||||
},
|
||||
})),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Atom, Sparkles, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -18,12 +18,20 @@ import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL'
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
|
||||
import {
|
||||
AI_QUERY_MODE,
|
||||
listQueryModes,
|
||||
type PanelQueryMode,
|
||||
} from '../../Panels/types/queryModes';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { toPanelType } from '../../Panels/types/panelKind';
|
||||
import { getQueryMode, withQueryMode } from '../../Panels/utils/queryMode';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
@@ -66,13 +74,18 @@ function PanelEditorQueryBuilder({
|
||||
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
|
||||
|
||||
// Derived, not stored: the AI mode is a per-query tag, so the active tab is whatever the
|
||||
// queries currently say. A mode the kind no longer offers (after a kind switch) falls back
|
||||
// to the builder rather than selecting a tab that isn't rendered.
|
||||
const activeMode = getQueryMode(currentQuery);
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
queryType: queryType as EQueryType,
|
||||
});
|
||||
(mode: string): void => {
|
||||
redirectWithQueryBuilderData(
|
||||
withQueryMode(currentQuery, mode as PanelQueryMode),
|
||||
);
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
@@ -99,7 +112,9 @@ function PanelEditorQueryBuilder({
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const { supportedQueryTypes } = panelDefinition;
|
||||
const supportedModes = listQueryModes(
|
||||
panelDefinition.supportedQueryModes,
|
||||
).filter((mode) => mode !== AI_QUERY_MODE || isAIObservabilityEnabled);
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
@@ -134,19 +149,48 @@ function PanelEditorQueryBuilder({
|
||||
label: 'PromQL',
|
||||
component: <PromQLQueryContainer />,
|
||||
},
|
||||
// Traces only, and the source selector is hidden with it: an AI query that moved
|
||||
// off traces is no longer an AI query. `queryVariant: 'static'` matches the AI
|
||||
// explorer's builder.
|
||||
[AI_QUERY_MODE]: {
|
||||
icon: <Sparkles size={14} />,
|
||||
label: 'AI Query Builder',
|
||||
component: (
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={false}
|
||||
version="v3"
|
||||
queryComponents={{}}
|
||||
config={{
|
||||
initialDataSource: DataSource.TRACES,
|
||||
queryVariant: 'static',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return supportedQueryTypes.map((queryType) => ({
|
||||
key: queryType,
|
||||
return supportedModes.map((mode) => ({
|
||||
key: mode,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
{queryTypeComponents[queryType].icon}
|
||||
<Typography>{queryTypeComponents[queryType].label}</Typography>
|
||||
{queryTypeComponents[mode].icon}
|
||||
<Typography>{queryTypeComponents[mode].label}</Typography>
|
||||
</div>
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
children: queryTypeComponents[mode].component,
|
||||
}));
|
||||
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
}, [
|
||||
panelDefinition,
|
||||
panelType,
|
||||
filterConfigs,
|
||||
isDarkMode,
|
||||
isListViewPanel,
|
||||
isAIObservabilityEnabled,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -161,7 +205,7 @@ function PanelEditorQueryBuilder({
|
||||
className={cx(styles.tabsContainer, {
|
||||
[styles.stickyNav]: stickyHeader,
|
||||
})}
|
||||
activeKey={currentQuery.queryType}
|
||||
activeKey={activeMode}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span className={styles.runQueryBtnContainer}>
|
||||
|
||||
@@ -12,6 +12,10 @@ import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
|
||||
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
|
||||
const mockQueryBuilderV2 = jest.fn();
|
||||
|
||||
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
|
||||
useIsAIObservabilityEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
|
||||
import { getQueryMode } from '../Panels/utils/queryMode';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import PanelEditorLayout, {
|
||||
@@ -319,7 +320,7 @@ function QueryEditorBody({
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={currentQuery.queryType}
|
||||
mode={getQueryMode(currentQuery)}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
stepInterval={stepInterval}
|
||||
|
||||
@@ -122,7 +122,7 @@ function StaticEditorBody({
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
mode={EQueryType.QUERY_BUILDER}
|
||||
legendSeries={[]}
|
||||
tableColumns={[]}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,10 @@ import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContai
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
|
||||
useIsAIObservabilityEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
const { useHistory: useRouterHistory } =
|
||||
jest.requireActual('react-router-dom');
|
||||
|
||||
@@ -5,7 +5,7 @@ import { handleQueryChange } from 'lib/query/panelQuery';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../../Panels/capabilities';
|
||||
import { resolveQueryMode } from '../../../Panels/capabilities';
|
||||
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
|
||||
@@ -18,7 +18,7 @@ jest.mock('lib/query/panelQuery', () => ({
|
||||
handleQueryChange: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../Panels/capabilities', () => ({
|
||||
resolveQueryType: jest.fn(),
|
||||
resolveQueryMode: jest.fn(),
|
||||
// Real predicate: these specs use real (query) kinds and the static path is
|
||||
// exercised through its own cases below.
|
||||
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
|
||||
@@ -36,7 +36,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
|
||||
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
|
||||
const mockResolveQueryMode = resolveQueryMode as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
|
||||
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
|
||||
@@ -95,7 +95,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
|
||||
// The guard owns coercion (tested in capabilities.test.ts); here it always
|
||||
// resolves to Query Builder so the coerced type flows into handleQueryChange.
|
||||
mockResolveQueryType.mockReturnValue('builder');
|
||||
mockResolveQueryMode.mockReturnValue('builder');
|
||||
});
|
||||
|
||||
it('does nothing when switching to the current kind', () => {
|
||||
@@ -185,16 +185,54 @@ describe('usePanelTypeSwitch', () => {
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// The hook asks the guard to resolve the active query type against the new kind…
|
||||
expect(mockResolveQueryType).toHaveBeenCalledWith(
|
||||
// The hook asks the guard to resolve the active mode against the new kind, passing
|
||||
// the current signal — the AI mode is only valid for traces.
|
||||
expect(mockResolveQueryMode).toHaveBeenCalledWith(
|
||||
'signoz/ListPanel',
|
||||
'promql',
|
||||
'logs',
|
||||
);
|
||||
// …and the resolved type ('builder') flows into the query rebuild.
|
||||
// …and the resolved mode ('builder') flows into the query rebuild.
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
expect((queryArg as Query).queryType).toBe('builder');
|
||||
});
|
||||
|
||||
it('strips the AI tag when the new kind has no AI mode', () => {
|
||||
const setSpec = jest.fn();
|
||||
const aiQuery = {
|
||||
id: 'ai',
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
} as unknown as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(aiQuery));
|
||||
// List declares no AI mode, so the guard coerces back to the builder.
|
||||
mockResolveQueryMode.mockReturnValue('builder');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
expect(mockResolveQueryMode).toHaveBeenCalledWith(
|
||||
'signoz/ListPanel',
|
||||
'builder_ai_query',
|
||||
'logs',
|
||||
);
|
||||
// The tag has to come off the queries themselves — queryType alone can't say it.
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
(queryArg as Query).builder.queryData.forEach((qd) => {
|
||||
expect(qd.builderQueryType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the original kind verbatim on switch-back (reversibility)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import {
|
||||
getSupportedSignals,
|
||||
requireQueryPanelDefinition,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
@@ -91,7 +94,7 @@ export function usePanelEditSession({
|
||||
// View modal shells) — asserted rather than assumed.
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const panelType = toPanelType(panelKind);
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
const defaultSignal = getSupportedSignals(panelKind)[0];
|
||||
|
||||
const query = usePanelQuery({
|
||||
panel: draft,
|
||||
|
||||
@@ -18,7 +18,8 @@ import type {
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
|
||||
import { isStaticPanelKind, resolveQueryMode } from '../../Panels/capabilities';
|
||||
import { getQueryMode, withQueryMode } from '../../Panels/utils/queryMode';
|
||||
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
@@ -147,12 +148,16 @@ export function usePanelTypeSwitch({
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit → coerce the query type if the new kind disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const queryType = resolveQueryType(newKind, query.queryType);
|
||||
// First visit → coerce the mode if the new kind disallows it, then rebuild the
|
||||
// builder query for it. Going through the mode (not `queryType`) is what lets an
|
||||
// AI query lose its per-query tag when the new kind has no AI mode: `queryType`
|
||||
// alone cannot express that.
|
||||
const currentSignal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
const mode = resolveQueryMode(newKind, getQueryMode(query), currentSignal);
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...query, queryType },
|
||||
withQueryMode(query, mode),
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
@@ -160,12 +165,9 @@ export function usePanelTypeSwitch({
|
||||
newKind === 'signoz/ListPanel'
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
getSwitchedPluginSpec(currentSpec, newKind, currentSignal),
|
||||
toPerses(nextQuery, newPanelType),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
getSupportedSignals,
|
||||
isPanelCombinationValid,
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isQueryModeSupportedByPanelKind,
|
||||
isSignalSupported,
|
||||
resolveQueryMode,
|
||||
resolveQueryType,
|
||||
} from '../capabilities';
|
||||
import { AI_QUERY_MODE } from '../types/queryModes';
|
||||
import type { PanelKind } from '../types/panelKind';
|
||||
|
||||
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
|
||||
@@ -142,7 +145,7 @@ describe('panel capabilities guard', () => {
|
||||
expect(getSupportedQueryTypes(unknownKind)).toStrictEqual([]);
|
||||
expect(isSignalSupported(unknownKind, logs)).toBe(false);
|
||||
expect(
|
||||
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
|
||||
isPanelCombinationValid({ kind: unknownKind, mode: QUERY_BUILDER }),
|
||||
).toBe(false);
|
||||
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
|
||||
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
|
||||
@@ -212,13 +215,13 @@ describe('panel capabilities guard', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
queryType: PROM,
|
||||
mode: PROM,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
mode: QUERY_BUILDER,
|
||||
signal: logs,
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -226,10 +229,10 @@ describe('panel capabilities guard', () => {
|
||||
|
||||
it('rejects an unsupported query type', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({ kind: 'signoz/ListPanel', queryType: PROM }),
|
||||
isPanelCombinationValid({ kind: 'signoz/ListPanel', mode: PROM }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPanelCombinationValid({ kind: 'signoz/TablePanel', queryType: PROM }),
|
||||
isPanelCombinationValid({ kind: 'signoz/TablePanel', mode: PROM }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -237,7 +240,7 @@ describe('panel capabilities guard', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
mode: QUERY_BUILDER,
|
||||
signal: metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -247,7 +250,7 @@ describe('panel capabilities guard', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
mode: QUERY_BUILDER,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -268,6 +271,85 @@ describe('panel capabilities guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the AI query mode', () => {
|
||||
const AI_KINDS: PanelKind[] = [
|
||||
'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel',
|
||||
'signoz/NumberPanel',
|
||||
'signoz/HistogramPanel',
|
||||
'signoz/PieChartPanel',
|
||||
'signoz/TablePanel',
|
||||
];
|
||||
|
||||
it.each(AI_KINDS)('is offered by %s, for traces only', (kind) => {
|
||||
expect(isQueryModeSupportedByPanelKind(kind, AI_QUERY_MODE)).toBe(true);
|
||||
expect(getSupportedSignals(kind, AI_QUERY_MODE)).toStrictEqual([traces]);
|
||||
});
|
||||
|
||||
it('is not offered by List, whose raw rows carry no aggregation', () => {
|
||||
expect(
|
||||
isQueryModeSupportedByPanelKind('signoz/ListPanel', AI_QUERY_MODE),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not leak into the legacy queryType axis', () => {
|
||||
expect(getSupportedQueryTypes('signoz/TimeSeriesPanel')).not.toContain(
|
||||
AI_QUERY_MODE,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the kind-wide signal list unchanged', () => {
|
||||
// traces is already in the builder mode's list, so the union must not repeat it.
|
||||
expect(getSupportedSignals('signoz/TimeSeriesPanel')).toStrictEqual([
|
||||
metrics,
|
||||
logs,
|
||||
traces,
|
||||
]);
|
||||
});
|
||||
|
||||
it('pairs with traces but not with the other signals of the kind', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
mode: AI_QUERY_MODE,
|
||||
signal: traces,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPanelCombinationValid({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
mode: AI_QUERY_MODE,
|
||||
signal: logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQueryMode', () => {
|
||||
it('keeps the AI mode on a kind that offers it', () => {
|
||||
expect(
|
||||
resolveQueryMode('signoz/TimeSeriesPanel', AI_QUERY_MODE, traces),
|
||||
).toBe(AI_QUERY_MODE);
|
||||
});
|
||||
|
||||
it('coerces the AI mode on a kind that does not offer it', () => {
|
||||
expect(resolveQueryMode('signoz/ListPanel', AI_QUERY_MODE, traces)).toBe(
|
||||
QUERY_BUILDER,
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces the AI mode when the signal moved off traces', () => {
|
||||
expect(resolveQueryMode('signoz/TimeSeriesPanel', AI_QUERY_MODE, logs)).toBe(
|
||||
QUERY_BUILDER,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a query-language mode alone', () => {
|
||||
expect(resolveQueryMode('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
|
||||
expect(resolveQueryMode('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHiddenQueryBuilderFields', () => {
|
||||
it('returns {} for kinds that declare no field rules', () => {
|
||||
expect(
|
||||
|
||||
@@ -8,10 +8,16 @@ import {
|
||||
} from './types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
import {
|
||||
listQueryModes,
|
||||
listQueryTypes,
|
||||
signalsForMode,
|
||||
type PanelQueryMode,
|
||||
} from './types/queryModes';
|
||||
|
||||
/**
|
||||
* The single deterministic guard for V2 dashboards. Every "what works with what"
|
||||
* question — panel kind × query type × signal, and which query-builder fields a kind
|
||||
* question — panel kind × query mode × signal, and which query-builder fields a kind
|
||||
* hides — is answered here by reading each kind's declared capabilities from the panel
|
||||
* registry. Adding a new kind means declaring its capabilities once in its definition;
|
||||
* these functions then cover it automatically. Pure and side-effect free.
|
||||
@@ -52,23 +58,47 @@ export function requireQueryPanelDefinition(
|
||||
return definition;
|
||||
}
|
||||
|
||||
/** Signals a kind can visualize. */
|
||||
/** Every mode this kind offers, in declaration order (the builder first). */
|
||||
export function getSupportedQueryModes(kind: PanelKind): PanelQueryMode[] {
|
||||
const definition = getQueryPanelDefinition(kind);
|
||||
return definition ? listQueryModes(definition.supportedQueryModes) : [];
|
||||
}
|
||||
|
||||
export function isQueryModeSupportedByPanelKind(
|
||||
kind: PanelKind,
|
||||
mode: PanelQueryMode,
|
||||
): boolean {
|
||||
return getSupportedQueryModes(kind).includes(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals a kind can visualize — in `mode` when one is given, else across every mode it
|
||||
* offers. The mode-scoped form is what keeps the two axes interoperable: the AI mode
|
||||
* authors traces only, on a kind whose builder mode also takes logs and metrics.
|
||||
*/
|
||||
export function getSupportedSignals(
|
||||
kind: PanelKind,
|
||||
mode?: PanelQueryMode,
|
||||
): TelemetrytypesSignalDTO[] {
|
||||
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
|
||||
const modes = getQueryPanelDefinition(kind)?.supportedQueryModes;
|
||||
return modes ? signalsForMode(modes, mode) : [];
|
||||
}
|
||||
|
||||
export function isSignalSupported(
|
||||
kind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
mode?: PanelQueryMode,
|
||||
): boolean {
|
||||
return getSupportedSignals(kind).includes(signal);
|
||||
return getSupportedSignals(kind, mode).includes(signal);
|
||||
}
|
||||
|
||||
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
/**
|
||||
* Query languages a kind supports (Query Builder / ClickHouse / PromQL) — its modes
|
||||
* minus the AI one, for the call sites that speak the legacy `queryType` axis.
|
||||
*/
|
||||
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
|
||||
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
|
||||
const definition = getQueryPanelDefinition(kind);
|
||||
return definition ? listQueryTypes(definition.supportedQueryModes) : [];
|
||||
}
|
||||
|
||||
export function isQueryTypeSupportedByPanelKind(
|
||||
@@ -79,48 +109,63 @@ export function isQueryTypeSupportedByPanelKind(
|
||||
}
|
||||
|
||||
/**
|
||||
* Master guard: is this panel kind renderable with this query type (and, in builder
|
||||
* mode, this signal)? ClickHouse/PromQL queries carry no signal, so the signal is
|
||||
* validated only when one is given.
|
||||
* Master guard: is this panel kind renderable in this mode (and, where the mode carries
|
||||
* a signal, with this signal)? ClickHouse/PromQL queries carry no signal, so the signal
|
||||
* is validated only when one is given.
|
||||
*/
|
||||
export function isPanelCombinationValid({
|
||||
kind,
|
||||
queryType,
|
||||
mode,
|
||||
signal,
|
||||
}: {
|
||||
kind: PanelKind;
|
||||
queryType: EQueryType;
|
||||
mode: PanelQueryMode;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}): boolean {
|
||||
// A query-less kind ignores the query entirely, so it pairs with anything.
|
||||
if (isStaticPanelKind(kind)) {
|
||||
return true;
|
||||
}
|
||||
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
|
||||
if (!isQueryModeSupportedByPanelKind(kind, mode)) {
|
||||
return false;
|
||||
}
|
||||
if (signal !== undefined && !isSignalSupported(kind, signal)) {
|
||||
if (signal !== undefined && !isSignalSupported(kind, signal, mode)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query type to use for a kind given a `preferred` one: keep it if the kind
|
||||
* supports it, otherwise fall back to the kind's first supported type. Used when
|
||||
* switching panel kinds to coerce an unsupported active query type (e.g. PromQL → a
|
||||
* List panel coerces to Query Builder).
|
||||
* The mode to use for a kind given a `preferred` one: keep it if the kind offers it and
|
||||
* it admits the signal, otherwise fall back to the kind's first mode. Used when switching
|
||||
* panel kinds to coerce an unsupported active mode (PromQL → a List panel coerces to Query
|
||||
* Builder; AI → a kind with no AI mode does the same).
|
||||
*/
|
||||
export function resolveQueryMode(
|
||||
kind: PanelKind,
|
||||
preferred: PanelQueryMode,
|
||||
signal?: TelemetrytypesSignalDTO,
|
||||
): PanelQueryMode {
|
||||
const supported = getSupportedQueryModes(kind);
|
||||
if (
|
||||
supported.includes(preferred) &&
|
||||
(signal === undefined || isSignalSupported(kind, signal, preferred))
|
||||
) {
|
||||
return preferred;
|
||||
}
|
||||
// A query-less kind has no modes; the builder is the neutral answer.
|
||||
return supported[0] ?? EQueryType.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
/** `resolveQueryMode` narrowed to the legacy `queryType` axis. */
|
||||
export function resolveQueryType(
|
||||
kind: PanelKind,
|
||||
preferred: EQueryType,
|
||||
): EQueryType {
|
||||
const supported = getSupportedQueryTypes(kind);
|
||||
if (supported.includes(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
// A query-less kind has no supported types; the builder is the neutral answer.
|
||||
return supported[0] ?? EQueryType.QUERY_BUILDER;
|
||||
return supported.includes(preferred)
|
||||
? preferred
|
||||
: (supported[0] ?? EQueryType.QUERY_BUILDER);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { AI_QUERY_MODE } from '../../types/queryModes';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModes: {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
kind: 'signal',
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
|
||||
[EQueryType.PROM]: { kind: 'signal-less' },
|
||||
[AI_QUERY_MODE]: {
|
||||
kind: 'signal',
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
// Bars are binned client-side from a raw time series, so the request asks for a
|
||||
// step interval wide enough to keep the bar count readable (V1 parity).
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { AI_QUERY_MODE } from '../../types/queryModes';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModes: {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
kind: 'signal',
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
|
||||
[EQueryType.PROM]: { kind: 'signal-less' },
|
||||
[AI_QUERY_MODE]: {
|
||||
kind: 'signal',
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
// Buckets are computed client-side from the raw series, so the request is a plain
|
||||
// time series — the bucket count is a display concern, not a query one.
|
||||
|
||||
@@ -18,16 +18,18 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
icon: List,
|
||||
Renderer,
|
||||
EditorPane: ListEditorPane,
|
||||
// Raw records come from logs and traces; metrics don't produce row data.
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
// Raw records come from logs and traces; metrics don't produce row data. No AI mode:
|
||||
// the AI builder authors aggregations, which raw rows have no place for.
|
||||
supportedQueryModes: {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
kind: 'signal',
|
||||
signals: [TelemetrytypesSignalDTO.logs, TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
// Raw rows have no aggregation, so step interval / having never apply, and the
|
||||
// Where clause searches the log/span body via `body CONTAINS`. Traces additionally
|
||||
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
|
||||
// list configs — the capabilities guard is the single source for both.
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
|
||||
queryBuilderFields: {
|
||||
default: {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { AI_QUERY_MODE } from '../../types/queryModes';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
displayName: 'Number',
|
||||
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModes: {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
kind: 'signal',
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
|
||||
[EQueryType.PROM]: { kind: 'signal-less' },
|
||||
[AI_QUERY_MODE]: {
|
||||
kind: 'signal',
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { AI_QUERY_MODE } from '../../types/queryModes';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
displayName: 'Pie Chart',
|
||||
@@ -18,12 +20,21 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
supportedQueryModes: {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
kind: 'signal',
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
|
||||
[AI_QUERY_MODE]: {
|
||||
kind: 'signal',
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { AI_QUERY_MODE } from '../../types/queryModes';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
kind: 'signoz/TablePanel',
|
||||
displayName: 'Table',
|
||||
@@ -18,12 +20,21 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
supportedQueryModes: {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
kind: 'signal',
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
|
||||
[AI_QUERY_MODE]: {
|
||||
kind: 'signal',
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
// The only kind that asks the server to transpose its scalar result into UI rows.
|
||||
queryCapabilities: {
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { AI_QUERY_MODE } from '../../types/queryModes';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Time Series',
|
||||
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModes: {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
kind: 'signal',
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
|
||||
[EQueryType.PROM]: { kind: 'signal-less' },
|
||||
[AI_QUERY_MODE]: {
|
||||
kind: 'signal',
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
|
||||
@@ -25,8 +25,7 @@ export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections: [],
|
||||
supportedSignals: [],
|
||||
supportedQueryTypes: [],
|
||||
supportedQueryModes: {},
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ChartLine } from '@signozhq/icons';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
@@ -13,6 +12,7 @@ import type {
|
||||
PanelQueryCapabilities,
|
||||
QueryBuilderFieldRule,
|
||||
} from './panelCapabilities';
|
||||
import type { SupportedQueryModes } from './queryModes';
|
||||
import type {
|
||||
BaseRendererProps,
|
||||
PanelRendererProps,
|
||||
@@ -114,10 +114,8 @@ export interface QueryPanelDefinition<
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
|
||||
EditorPane: ComponentType<QueryEditorPaneProps>;
|
||||
/** Signals this kind can visualize. */
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
supportedQueryTypes: EQueryType[];
|
||||
/** Modes this kind offers, each with the signals authorable in it. */
|
||||
supportedQueryModes: SupportedQueryModes;
|
||||
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
|
||||
queryBuilderFields: QueryBuilderFieldRule;
|
||||
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { QueryType } from 'types/api/v5/queryRange';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
/**
|
||||
* The query-authoring modes a panel kind can offer, one per editor tab. Three are query
|
||||
* languages (`EQueryType`); the fourth is the AI query builder, which is a builder query
|
||||
* under the hood and so has no `EQueryType` of its own — it is keyed by the v5 query type
|
||||
* stamped on each of its queries (`IBuilderQuery.builderQueryType`), which is what lets
|
||||
* the active tab be read straight off the query.
|
||||
*/
|
||||
export type PanelQueryMode =
|
||||
| EQueryType
|
||||
| Extract<QueryType, 'builder_ai_query'>;
|
||||
|
||||
/** The AI query builder's mode key. */
|
||||
export const AI_QUERY_MODE = 'builder_ai_query' as const;
|
||||
|
||||
/**
|
||||
* What a mode authors: a set of signals, or nothing signal-shaped at all. ClickHouse and
|
||||
* PromQL are raw query text and carry no signal, which is a different statement from "this
|
||||
* mode takes an empty set of signals" — the union makes the two unconfusable.
|
||||
*/
|
||||
export type QueryModeCapability =
|
||||
| { kind: 'signal'; signals: TelemetrytypesSignalDTO[] }
|
||||
| { kind: 'signal-less' };
|
||||
|
||||
/**
|
||||
* Every mode a kind offers, with what each one authors. Partial on purpose: an absent key
|
||||
* means the kind does not offer that mode at all (no tab, and the kind is disabled while that
|
||||
* mode is active), which is how List opts out of everything but the builder.
|
||||
*
|
||||
* Declaring modes and signals together is what keeps them interoperable: AI is traces-only on
|
||||
* a kind whose builder mode also takes logs and metrics — which two independent lists can't
|
||||
* express.
|
||||
*/
|
||||
export type SupportedQueryModes = Partial<
|
||||
Record<PanelQueryMode, QueryModeCapability>
|
||||
>;
|
||||
|
||||
/** Declaration order, which puts the builder first. */
|
||||
export function listQueryModes(modes: SupportedQueryModes): PanelQueryMode[] {
|
||||
return Object.keys(modes) as PanelQueryMode[];
|
||||
}
|
||||
|
||||
/** The modes that are query languages, for call sites on the legacy `queryType` axis. */
|
||||
export function listQueryTypes(modes: SupportedQueryModes): EQueryType[] {
|
||||
return listQueryModes(modes).filter(
|
||||
(mode): mode is EQueryType => mode !== AI_QUERY_MODE,
|
||||
);
|
||||
}
|
||||
|
||||
/** Signals authorable in `mode`, or across every mode when none is given. */
|
||||
export function signalsForMode(
|
||||
modes: SupportedQueryModes,
|
||||
mode?: PanelQueryMode,
|
||||
): TelemetrytypesSignalDTO[] {
|
||||
if (mode) {
|
||||
const capability = modes[mode];
|
||||
return capability?.kind === 'signal' ? capability.signals : [];
|
||||
}
|
||||
const all = Object.values(modes).flatMap((capability) =>
|
||||
capability?.kind === 'signal' ? capability.signals : [],
|
||||
);
|
||||
return [...new Set(all)];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getQueryPanelDefinition } from '../capabilities';
|
||||
import { getQueryPanelDefinition, getSupportedSignals } from '../capabilities';
|
||||
import { toPanelType } from '../types/panelKind';
|
||||
import { fromPerses } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
@@ -22,7 +22,7 @@ export function getPanelBuilderQuery(
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
const [defaultSignal] = definition.supportedSignals;
|
||||
const [defaultSignal] = getSupportedSignals(kind);
|
||||
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
|
||||
// metrics default isn't authorable in every kind (e.g. List).
|
||||
if (panel.spec.queries.length === 0 && defaultSignal) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { initialQueryAIWithType } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { AI_QUERY_MODE, type PanelQueryMode } from '../types/queryModes';
|
||||
|
||||
/**
|
||||
* The mode a query is authored in. The AI mode has no `queryType` of its own — it is a
|
||||
* builder query tagged per-query — so it is read off the queries rather than off the
|
||||
* envelope around them.
|
||||
*/
|
||||
export function getQueryMode(query: Query): PanelQueryMode {
|
||||
if (
|
||||
query.queryType === EQueryType.QUERY_BUILDER &&
|
||||
(query.builder?.queryData ?? []).some(
|
||||
(q) => q.builderQueryType === AI_QUERY_MODE,
|
||||
)
|
||||
) {
|
||||
return AI_QUERY_MODE;
|
||||
}
|
||||
return query.queryType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retargets a query at `mode`, which is the only safe way to change it: the AI tag lives on
|
||||
* every query and has to be stamped or cleared in step with `queryType`, or the tab and the
|
||||
* queries disagree.
|
||||
*
|
||||
* Entering the AI mode keeps queries that are already traces (the common case — the user was
|
||||
* building a trace query and wants AI's field set); anything else is replaced with a fresh AI
|
||||
* query rather than coerced, which would leave metric aggregations on a traces query.
|
||||
*/
|
||||
export function withQueryMode(query: Query, mode: PanelQueryMode): Query {
|
||||
if (mode === AI_QUERY_MODE) {
|
||||
const queryData = query.builder?.queryData ?? [];
|
||||
const isAllTraces =
|
||||
queryData.length > 0 &&
|
||||
queryData.every((q) => q.dataSource === DataSource.TRACES);
|
||||
return {
|
||||
...query,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: isAllTraces
|
||||
? {
|
||||
...query.builder,
|
||||
queryData: queryData.map((q) => ({
|
||||
...q,
|
||||
builderQueryType: AI_QUERY_MODE,
|
||||
})),
|
||||
}
|
||||
: initialQueryAIWithType.builder,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...query,
|
||||
queryType: mode,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: (query.builder?.queryData ?? []).map((q) => ({
|
||||
...q,
|
||||
builderQueryType: undefined,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -60,7 +60,7 @@ function QueryViewModalBody({
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType,
|
||||
queryMode,
|
||||
query,
|
||||
runQuery,
|
||||
resetQuery,
|
||||
@@ -152,7 +152,7 @@ function QueryViewModalBody({
|
||||
}}
|
||||
onSwitchToEdit={onSwitchToEdit}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
queryMode={queryMode}
|
||||
signal={signal}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
onResetQuery={resetQuery}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { usePanelTypeSelectItems } from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
|
||||
import ConfigSelect from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
|
||||
import type { PanelQueryMode } from '../../../Panels/types/queryModes';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -41,7 +42,7 @@ interface QueryViewModalHeaderProps extends ViewPanelModalHeaderBaseProps {
|
||||
* selector greys out kinds that can't be authored in it — e.g. List is
|
||||
* Query-Builder-only, so PromQL/ClickHouse disable it.
|
||||
*/
|
||||
queryType: EQueryType;
|
||||
queryMode: PanelQueryMode;
|
||||
/** Current builder datasource — greys out kinds that don't support it (e.g. List needs logs/traces, not metrics). */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Restore the saved query + kind (drilldown reset). */
|
||||
@@ -74,7 +75,7 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({
|
||||
queryType: query?.queryType ?? EQueryType.QUERY_BUILDER,
|
||||
mode: query?.queryMode ?? EQueryType.QUERY_BUILDER,
|
||||
signal: query?.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,12 +13,13 @@ import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/Dashboa
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
|
||||
import { getQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/queryMode';
|
||||
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import {
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
import type { PanelQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/types/queryModes';
|
||||
|
||||
interface UseViewPanelModeArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -43,7 +44,7 @@ export interface UseViewPanelModeReturn {
|
||||
*/
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
|
||||
queryType: EQueryType;
|
||||
queryMode: PanelQueryMode;
|
||||
/** Query result for the draft over the per-view window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
|
||||
@@ -132,7 +133,7 @@ export function useViewPanelMode({
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType: currentQuery.queryType,
|
||||
queryMode: getQueryMode(currentQuery),
|
||||
query,
|
||||
runQuery,
|
||||
resetQuery,
|
||||
|
||||
@@ -46,6 +46,10 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
|
||||
useIsAIObservabilityEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () =>
|
||||
jest
|
||||
.requireActual('tests/browser-history-safe-navigate')
|
||||
|
||||
@@ -16,6 +16,10 @@ import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
|
||||
useIsAIObservabilityEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () =>
|
||||
jest
|
||||
.requireActual('tests/browser-history-safe-navigate')
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { isQueryTypeSupportedByPanelKind } from '../DashboardContainer/Panels/capabilities';
|
||||
import { isQueryModeSupportedByPanelKind } from '../DashboardContainer/Panels/capabilities';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
@@ -22,7 +22,7 @@ jest.mock('../DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
jest.mock('../DashboardContainer/Panels/capabilities', () => ({
|
||||
isQueryTypeSupportedByPanelKind: jest.fn(),
|
||||
isQueryModeSupportedByPanelKind: jest.fn(),
|
||||
// Real predicate: these specs exercise query kinds; the static guard has its
|
||||
// own case below.
|
||||
isStaticPanelKind: jest.requireActual(
|
||||
@@ -34,7 +34,7 @@ const mockToPerses = toPerses as jest.Mock;
|
||||
const mockBuildDefaultQueries = buildDefaultQueries as jest.Mock;
|
||||
const mockBuildPluginSpec = buildPluginSpec as jest.Mock;
|
||||
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
|
||||
const mockIsQueryTypeSupported = isQueryTypeSupportedByPanelKind as jest.Mock;
|
||||
const mockIsQueryModeSupported = isQueryModeSupportedByPanelKind as jest.Mock;
|
||||
|
||||
const DEFAULT_QUERIES = [{ kind: 'default' }];
|
||||
const CONVERTED_QUERIES = [{ kind: 'converted' }];
|
||||
@@ -54,7 +54,7 @@ describe('buildNewPanelSeed', () => {
|
||||
mockBuildDefaultQueries.mockReturnValue(DEFAULT_QUERIES);
|
||||
mockBuildPluginSpec.mockReturnValue(BASE_SPEC);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
|
||||
mockIsQueryTypeSupported.mockReturnValue(true);
|
||||
mockIsQueryModeSupported.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('uses the kind default seed when it is not an explorer export', () => {
|
||||
@@ -77,7 +77,7 @@ describe('buildNewPanelSeed', () => {
|
||||
});
|
||||
|
||||
it('coerces a builder-only kind to Table for a ClickHouse query', () => {
|
||||
mockIsQueryTypeSupported.mockReturnValue(false);
|
||||
mockIsQueryModeSupported.mockReturnValue(false);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
|
||||
const seed = buildNewPanelSeed(
|
||||
@@ -91,7 +91,7 @@ describe('buildNewPanelSeed', () => {
|
||||
});
|
||||
|
||||
it('coerces a builder-only kind to TimeSeries for a PromQL query', () => {
|
||||
mockIsQueryTypeSupported.mockReturnValue(false);
|
||||
mockIsQueryModeSupported.mockReturnValue(false);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
|
||||
const seed = buildNewPanelSeed(
|
||||
|
||||
@@ -4,13 +4,14 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
isStaticPanelKind,
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isQueryModeSupportedByPanelKind,
|
||||
} from '../DashboardContainer/Panels/capabilities';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { toPanelType } from '../DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelKind } from '../DashboardContainer/Panels/types/panelKind';
|
||||
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import { getQueryMode } from '../DashboardContainer/Panels/utils/queryMode';
|
||||
import {
|
||||
buildPluginSpec,
|
||||
type SeededPluginSpec,
|
||||
@@ -36,7 +37,9 @@ function resolveSeededPanelKind(
|
||||
requestedKind: PanelKind,
|
||||
compositeQuery: Query,
|
||||
): PanelKind {
|
||||
if (isQueryTypeSupportedByPanelKind(requestedKind, compositeQuery.queryType)) {
|
||||
if (
|
||||
isQueryModeSupportedByPanelKind(requestedKind, getQueryMode(compositeQuery))
|
||||
) {
|
||||
return requestedKind;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -10,23 +10,6 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/traces/{traceID}/summary", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetTraceSummary),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetTraceSummary",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get summary for a trace",
|
||||
Description: "Returns the trace-level fields of the waterfall (time range, root, span counts, missing spans) and, when the trace has gen_ai spans, its token and cost totals. Computed in one aggregate query.",
|
||||
Response: new(spantypes.GettableTraceSummary),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -20,27 +18,6 @@ func NewHandler(module tracedetail.Module) tracedetail.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (h *handler) GetTraceSummary(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.module.GetTraceStats(r.Context(), orgID, mux.Vars(r)["traceID"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, spantypes.NewGettableTraceSummary(stats))
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
@@ -40,21 +39,6 @@ func NewModule(traceStore spantypes.TraceStore, providerSettings factory.Provide
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *module) GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats, err := m.store.GetTraceStats(ctx, orgID, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stats.TotalSpans == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetWaterfallV4 is the OOM-safe V4 waterfall.
|
||||
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
|
||||
// minimal fields for all spans to build the tree, then full fields for the
|
||||
|
||||
@@ -10,15 +10,9 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
|
||||
@@ -44,18 +38,10 @@ type spanDurationRow struct {
|
||||
|
||||
type traceStore struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
storage qbtypes.Storage
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, fl flagger.Flagger) *traceStore {
|
||||
return &traceStore{
|
||||
telemetryStore: ts,
|
||||
metadataStore: metadataStore,
|
||||
storage: tracestelemetryschema.NewStorage(),
|
||||
flagger: fl,
|
||||
}
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore) *traceStore {
|
||||
return &traceStore{telemetryStore: ts}
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*spantypes.TraceSummary, error) {
|
||||
@@ -79,131 +65,6 @@ func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*span
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, summary *spantypes.TraceSummary) (*spantypes.TraceStats, error) {
|
||||
table := fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable)
|
||||
spans := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
genAIColumns, err := s.genAISpanColumns(ctx, orgID, summary, spans)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A span whose parent was never recorded hangs off a synthetic "Missing Span" root in the waterfall.
|
||||
ids := sqlbuilder.NewSelectBuilder()
|
||||
ids.Select("span_id")
|
||||
ids.From(table)
|
||||
ids.Where(
|
||||
ids.E("trace_id", traceID),
|
||||
ids.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
ids.LE("ts_bucket_start", summary.End.Unix()),
|
||||
)
|
||||
missingParent := fmt.Sprintf("parent_span_id <> '' AND parent_span_id GLOBAL NOT IN (%s)", spans.Var(ids))
|
||||
|
||||
spans.Select(
|
||||
"toUnixTimestamp64Nano(timestamp) AS span_start_ns",
|
||||
"span_start_ns + duration_nano AS span_end_ns",
|
||||
"span_id",
|
||||
"has_error",
|
||||
"("+missingParent+") AS has_missing_parent",
|
||||
"(parent_span_id = '' OR has_missing_parent) AS is_root",
|
||||
"if(parent_span_id = '', name, 'Missing Span') AS root_name",
|
||||
"if(parent_span_id = '', "+colServiceName+", '') AS root_service",
|
||||
)
|
||||
spans.SelectMore(genAIColumns...)
|
||||
spans.From(table)
|
||||
spans.Where(
|
||||
spans.E("trace_id", traceID),
|
||||
spans.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
spans.LE("ts_bucket_start", summary.End.Unix()),
|
||||
)
|
||||
spans.SQL("LIMIT 1 BY span_id")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"toUInt64(min(span_start_ns)) AS start_ns",
|
||||
"toUInt64(max(span_end_ns)) AS end_ns",
|
||||
"count() AS total_spans",
|
||||
"countIf(has_error) AS total_error_spans",
|
||||
"countIf(has_missing_parent) > 0 AS has_missing_spans",
|
||||
"argMinIf(root_service, (span_start_ns, root_name), is_root) AS root_service_name",
|
||||
"argMinIf(root_name, (span_start_ns, root_name), is_root) AS root_entry_point",
|
||||
"countIf(is_gen_ai) AS gen_ai_span_count",
|
||||
"toUInt64(coalesce(sum(input_tokens_value), 0)) AS input_tokens",
|
||||
"toUInt64(coalesce(sum(output_tokens_value), 0)) AS output_tokens",
|
||||
"toUInt64(coalesce(sum(cache_read_tokens_value), 0)) AS cache_read_tokens",
|
||||
"toUInt64(coalesce(sum(cache_write_tokens_value), 0)) AS cache_write_tokens",
|
||||
"toUInt64(coalesce(sum(reasoning_tokens_value), 0)) AS reasoning_tokens",
|
||||
"sum(total_cost_value) AS total_cost",
|
||||
)
|
||||
sb.From(sb.BuilderAs(spans, "spans"))
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var stats spantypes.TraceStats
|
||||
err = s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
|
||||
&stats.StartNs, &stats.EndNs, &stats.TotalSpans, &stats.TotalErrorSpans, &stats.HasMissingSpans,
|
||||
&stats.RootServiceName, &stats.RootEntryPoint, &stats.GenAISpanCount,
|
||||
&stats.Tokens.Input, &stats.Tokens.Output, &stats.Tokens.CacheRead, &stats.Tokens.CacheWrite, &stats.Tokens.Reasoning,
|
||||
&stats.TotalCost,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace stats")
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// genAISpanColumns renders the per-span gen_ai gate and value reads through the shared
|
||||
// traces storage, so each attribute is read from the column its evolutions place it in
|
||||
// over the trace's own time window. Exists predicates bind their args into sb.
|
||||
func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, summary *spantypes.TraceSummary, sb *sqlbuilder.SelectBuilder) ([]string, error) {
|
||||
// no data type: metadata reports token counts as number, so a float64 request would
|
||||
// miss them and fall back to a map read without evolutions
|
||||
attributeKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute}
|
||||
}
|
||||
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(aiobservabilitytypes.GenAISpanGateKeys)+len(spantypes.TraceStatsGenAIColumns))
|
||||
addSelector := func(name string) {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
})
|
||||
}
|
||||
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
|
||||
addSelector(name)
|
||||
}
|
||||
for _, col := range spantypes.TraceStatsGenAIColumns {
|
||||
addSelector(col.Key)
|
||||
}
|
||||
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(summary.Start.UnixNano()), uint64(summary.End.UnixNano()))
|
||||
|
||||
gate := make([]string, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
|
||||
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
|
||||
conds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name), qbtypes.FilterOperatorExists, nil, keys, false, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gate = append(gate, conds...)
|
||||
}
|
||||
columns := []string{sb.Or(gate...) + " AS is_gen_ai"}
|
||||
|
||||
for _, col := range spantypes.TraceStatsGenAIColumns {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(col.Key), telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// a materialized column name carries `$$`, which Build would otherwise unescape
|
||||
columns = append(columns, sqlbuilder.Escape(expr)+" AS "+col.Column+"_value")
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.StorableSpan, error) {
|
||||
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
|
||||
query := fmt.Sprintf(`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,12 +6,10 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
type Handler interface {
|
||||
GetTraceSummary(http.ResponseWriter, *http.Request)
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
GetTraceAggregations(http.ResponseWriter, *http.Request)
|
||||
GetFlamegraph(http.ResponseWriter, *http.Request)
|
||||
@@ -19,7 +17,6 @@ type Handler interface {
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
type Module interface {
|
||||
GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error)
|
||||
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
|
||||
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
|
||||
|
||||
@@ -495,8 +495,8 @@ WITH
|
||||
toDateTime64(%[3]d/1e9, 9) AS start_ts,
|
||||
toDateTime64(%[4]d/1e9, 9) AS end_ts,
|
||||
|
||||
(%[5]s,%[6]s) AS step1,
|
||||
(%[7]s,%[8]s) AS step2
|
||||
('%[5]s','%[6]s') AS step1,
|
||||
('%[7]s','%[8]s') AS step2
|
||||
|
||||
SELECT
|
||||
trace_id,
|
||||
@@ -527,10 +527,10 @@ LIMIT 5;
|
||||
containsErrorT2,
|
||||
startTs,
|
||||
endTs,
|
||||
clickhousesql.StringLiteral(serviceNameT1),
|
||||
clickhousesql.StringLiteral(spanNameT1),
|
||||
clickhousesql.StringLiteral(serviceNameT2),
|
||||
clickhousesql.StringLiteral(spanNameT2),
|
||||
serviceNameT1,
|
||||
spanNameT1,
|
||||
serviceNameT2,
|
||||
spanNameT2,
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
t1TimeExpr,
|
||||
@@ -571,8 +571,8 @@ WITH
|
||||
toDateTime64(%[3]d/1e9, 9) AS start_ts,
|
||||
toDateTime64(%[4]d/1e9, 9) AS end_ts,
|
||||
|
||||
(%[5]s,%[6]s) AS step1,
|
||||
(%[7]s,%[8]s) AS step2
|
||||
('%[5]s','%[6]s') AS step1,
|
||||
('%[7]s','%[8]s') AS step2
|
||||
|
||||
SELECT
|
||||
trace_id,
|
||||
@@ -607,10 +607,10 @@ LIMIT 5;
|
||||
containsErrorT2,
|
||||
startTs,
|
||||
endTs,
|
||||
clickhousesql.StringLiteral(serviceNameT1),
|
||||
clickhousesql.StringLiteral(spanNameT1),
|
||||
clickhousesql.StringLiteral(serviceNameT2),
|
||||
clickhousesql.StringLiteral(spanNameT2),
|
||||
serviceNameT1,
|
||||
spanNameT1,
|
||||
serviceNameT2,
|
||||
spanNameT2,
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
t1TimeExpr,
|
||||
|
||||
@@ -161,7 +161,7 @@ func NewModules(
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
|
||||
@@ -19,7 +19,6 @@ var (
|
||||
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.GenAIUsageReasoningOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageReasoningOutputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeFloat64),
|
||||
|
||||
aiobservabilitytypes.GenAIInputMessages: genAIAttribute(aiobservabilitytypes.GenAIInputMessages, telemetrytypes.FieldDataTypeString),
|
||||
|
||||
@@ -46,6 +46,7 @@ type QuerySettings struct {
|
||||
MaxBytesToRead int `mapstructure:"max_bytes_to_read"`
|
||||
MaxResultRows int `mapstructure:"max_result_rows"`
|
||||
IgnoreDataSkippingIndices string `mapstructure:"ignore_data_skipping_indices"`
|
||||
SecondaryIndicesEnableBulkFiltering bool `mapstructure:"secondary_indices_enable_bulk_filtering"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
|
||||
@@ -72,6 +72,10 @@ func (h *provider) BeforeQuery(ctx context.Context, _ *telemetrystore.QueryEvent
|
||||
settings["result_overflow_mode"] = ctx.Value("result_overflow_mode")
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): enable it when the "Cannot read all data" issue is fixed
|
||||
// https://github.com/ClickHouse/ClickHouse/issues/82283
|
||||
settings["secondary_indices_enable_bulk_filtering"] = false
|
||||
|
||||
ctx = clickhouse.Context(ctx, clickhouse.WithSettings(settings))
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ const (
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
GenAIUsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens"
|
||||
|
||||
GenAIInputMessages = "gen_ai.input.messages"
|
||||
GenAIOutputMessages = "gen_ai.output.messages"
|
||||
|
||||
@@ -1014,7 +1014,7 @@ func rejectHTTPBasicAuthBeyondPassword(channelName string, httpConfig *commoncfg
|
||||
|
||||
basicAuth := httpConfig.BasicAuth
|
||||
if *basicAuth != (commoncfg.BasicAuth{Username: basicAuth.Username, Password: basicAuth.Password}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth with fields other than username and password, which is not supported", channelName)
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1026,8 +1026,8 @@ func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commonc
|
||||
}
|
||||
|
||||
authorization := httpConfig.Authorization
|
||||
if !strings.EqualFold(authorization.Type, bearerAuthorizationType) || *authorization != (commoncfg.Authorization{Type: authorization.Type, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization with fields other than a bearer token, which is not supported", channelName)
|
||||
if *authorization != (commoncfg.Authorization{Type: bearerAuthorizationType, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -542,42 +542,3 @@ func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The HTTP auth scheme is case-insensitive (RFC 7235) and Alertmanager sends
|
||||
// the stored spelling verbatim, so a hand-written receiver may carry any casing.
|
||||
func TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
|
||||
sendResolved := config.DefaultWebhookConfig.VSendResolved
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
storedChannelData string
|
||||
expectedWebhookSpec *ChannelWebhookConfig
|
||||
}{
|
||||
{
|
||||
name: "CanonicalBearer",
|
||||
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://a","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://a", BearerToken: "tok"},
|
||||
},
|
||||
{
|
||||
name: "LowercaseBearer",
|
||||
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://b","http_config":{"authorization":{"type":"bearer","credentials":"lower"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://b", BearerToken: "lower"},
|
||||
},
|
||||
{
|
||||
name: "UppercaseBearer",
|
||||
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://c","http_config":{"authorization":{"type":"BEARER","credentials":"upper"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://c", BearerToken: "upper"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channel := Channel{DisplayName: "hook", Data: testCase.storedChannelData}
|
||||
|
||||
postable, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ChannelKindWebhook, postable.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedWebhookSpec, postable.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
@@ -27,7 +27,6 @@ type SpanMapperStore interface {
|
||||
// TraceStore defines the data access interface for trace detail queries.
|
||||
type TraceStore interface {
|
||||
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
|
||||
GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, summary *TraceSummary) (*TraceStats, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
|
||||
// TraceStatsGenAIColumns pairs each summed TraceStats column with the gen_ai attribute it sums.
|
||||
var TraceStatsGenAIColumns = []TraceStatsGenAIColumn{
|
||||
{Column: "input_tokens", Key: aiobservabilitytypes.GenAIUsageInputTokens},
|
||||
{Column: "output_tokens", Key: aiobservabilitytypes.GenAIUsageOutputTokens},
|
||||
{Column: "cache_read_tokens", Key: aiobservabilitytypes.GenAIUsageCacheReadInputTokens},
|
||||
{Column: "cache_write_tokens", Key: aiobservabilitytypes.GenAIUsageCacheCreationInputTokens},
|
||||
{Column: "reasoning_tokens", Key: aiobservabilitytypes.GenAIUsageReasoningOutputTokens},
|
||||
{Column: "total_cost", Key: aiobservabilitytypes.SignozGenAITotalCost},
|
||||
}
|
||||
|
||||
type TraceStatsGenAIColumn struct {
|
||||
Column string
|
||||
Key string
|
||||
}
|
||||
|
||||
// TraceStats is the single-row result of the trace summary aggregate query.
|
||||
type TraceStats struct {
|
||||
StartNs uint64
|
||||
EndNs uint64
|
||||
RootServiceName string
|
||||
RootEntryPoint string
|
||||
TotalSpans uint64
|
||||
TotalErrorSpans uint64
|
||||
HasMissingSpans bool
|
||||
GenAISpanCount uint64
|
||||
Tokens TraceAITokens
|
||||
TotalCost *float64
|
||||
}
|
||||
|
||||
// GettableTraceSummary is the response for the trace summary API; the trace-level
|
||||
// fields match the waterfall response.
|
||||
type GettableTraceSummary struct {
|
||||
StartTimestampMillis uint64 `json:"startTimestampMillis"`
|
||||
EndTimestampMillis uint64 `json:"endTimestampMillis"`
|
||||
RootServiceName string `json:"rootServiceName"`
|
||||
RootServiceEntryPoint string `json:"rootServiceEntryPoint"`
|
||||
TotalSpansCount uint64 `json:"totalSpansCount"`
|
||||
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount"`
|
||||
HasMissingSpans bool `json:"hasMissingSpans"`
|
||||
AI *TraceAISummary `json:"ai,omitempty"`
|
||||
}
|
||||
|
||||
// TraceAISummary is present when any span carries a gen_ai gate key.
|
||||
type TraceAISummary struct {
|
||||
Tokens TraceAITokens `json:"tokens"`
|
||||
// TotalCost is null when no span carries a cost attribute.
|
||||
TotalCost *float64 `json:"totalCost" nullable:"true"`
|
||||
}
|
||||
|
||||
type TraceAITokens struct {
|
||||
Input uint64 `json:"input"`
|
||||
Output uint64 `json:"output"`
|
||||
CacheRead uint64 `json:"cacheRead"`
|
||||
CacheWrite uint64 `json:"cacheWrite"`
|
||||
Reasoning uint64 `json:"reasoning"`
|
||||
}
|
||||
|
||||
func NewGettableTraceSummary(stats *TraceStats) *GettableTraceSummary {
|
||||
summary := &GettableTraceSummary{
|
||||
StartTimestampMillis: stats.StartNs / 1_000_000,
|
||||
EndTimestampMillis: stats.EndNs / 1_000_000,
|
||||
RootServiceName: stats.RootServiceName,
|
||||
RootServiceEntryPoint: stats.RootEntryPoint,
|
||||
TotalSpansCount: stats.TotalSpans,
|
||||
TotalErrorSpansCount: stats.TotalErrorSpans,
|
||||
HasMissingSpans: stats.HasMissingSpans,
|
||||
}
|
||||
if stats.GenAISpanCount > 0 {
|
||||
summary.AI = &TraceAISummary{Tokens: stats.Tokens, TotalCost: stats.TotalCost}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
1
tests/fixtures/traces.py
vendored
1
tests/fixtures/traces.py
vendored
@@ -895,7 +895,6 @@ _TRACES_TABLES_TO_TRUNCATE = [
|
||||
"span_attributes_keys",
|
||||
"signoz_error_index_v2",
|
||||
"top_level_operations",
|
||||
"trace_summary",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querierai import root_span
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
WATERFALL_FIELDS = (
|
||||
"startTimestampMillis",
|
||||
"endTimestampMillis",
|
||||
"rootServiceName",
|
||||
"rootServiceEntryPoint",
|
||||
"totalSpansCount",
|
||||
"totalErrorSpansCount",
|
||||
"hasMissingSpans",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attribute_backend", ["map", "json"])
|
||||
def test_summary_ai_trace(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
) -> None:
|
||||
"""The summary carries the waterfall's trace-level fields and, for a trace with gen_ai
|
||||
spans, token totals over every LLM span and the cost summed over the spans that carry it.
|
||||
Spans are written to one layout only, so a read from the wrong column sums to zero."""
|
||||
use_attribute_backend(attribute_backend)
|
||||
write_mode = "json_only" if attribute_backend == "json" else "legacy_only"
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = f"td-summary-{attribute_backend}"
|
||||
resources = {"service.name": service}
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={
|
||||
"gen_ai.request.model": "gpt-4o-mini",
|
||||
"gen_ai.usage.input_tokens": 100,
|
||||
"gen_ai.usage.output_tokens": 20,
|
||||
"gen_ai.usage.cache_read.input_tokens": 7,
|
||||
"_signoz.gen_ai.total_cost": 0.01,
|
||||
},
|
||||
attribute_write_mode=write_mode,
|
||||
),
|
||||
# a failed LLM call: counted in tokens and errors, but priced by nobody
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=3),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_ERROR,
|
||||
resources=resources,
|
||||
attributes={
|
||||
"gen_ai.request.model": "gpt-4o-mini",
|
||||
"gen_ai.usage.input_tokens": 50,
|
||||
"gen_ai.usage.output_tokens": 5,
|
||||
},
|
||||
attribute_write_mode=write_mode,
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="execute_tool",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.tool.name": "get_weather"},
|
||||
attribute_write_mode=write_mode,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
|
||||
|
||||
summary = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"), timeout=10, headers=headers)
|
||||
assert summary.status_code == HTTPStatus.OK, summary.text
|
||||
summary = summary.json()["data"]
|
||||
|
||||
waterfall = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"selectedSpanId": "", "uncollapsedSpans": []},
|
||||
)
|
||||
assert waterfall.status_code == HTTPStatus.OK, waterfall.text
|
||||
waterfall = waterfall.json()["data"]
|
||||
|
||||
assert {k: summary[k] for k in WATERFALL_FIELDS} == {k: waterfall[k] for k in WATERFALL_FIELDS}
|
||||
assert summary["rootServiceName"] == service
|
||||
assert summary["rootServiceEntryPoint"] == "POST /api/chat"
|
||||
assert summary["totalSpansCount"] == 4
|
||||
assert summary["totalErrorSpansCount"] == 1
|
||||
assert summary["hasMissingSpans"] is False
|
||||
|
||||
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 7, "cacheWrite": 0, "reasoning": 0}
|
||||
assert summary["ai"]["totalCost"] == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_summary_ai_trace_across_json_rollout(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
"""A trace that straddles the attribute JSON rollout has LLM spans written only to the legacy
|
||||
maps before it and to the JSON column after it. The summary window covers both, so the gen_ai
|
||||
reads must fall back across columns and sum every span."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
rollout = now - timedelta(minutes=30)
|
||||
seed_attribute_evolution("traces", rollout)
|
||||
|
||||
service = "td-summary-rollout"
|
||||
resources = {"service.name": service}
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=rollout - timedelta(minutes=10),
|
||||
duration=timedelta(minutes=15),
|
||||
trace_id=trace_id,
|
||||
span_id=root_id,
|
||||
parent_span_id="",
|
||||
name="long agent run",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attribute_write_mode="legacy_only",
|
||||
),
|
||||
Traces(
|
||||
timestamp=rollout - timedelta(minutes=5),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 100, "gen_ai.usage.output_tokens": 20, "_signoz.gen_ai.total_cost": 0.01},
|
||||
attribute_write_mode="legacy_only",
|
||||
),
|
||||
Traces(
|
||||
timestamp=rollout + timedelta(minutes=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 50, "gen_ai.usage.output_tokens": 5, "_signoz.gen_ai.total_cost": 0.02},
|
||||
attribute_write_mode="json_only",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
summary = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"),
|
||||
timeout=10,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert summary.status_code == HTTPStatus.OK, summary.text
|
||||
summary = summary.json()["data"]
|
||||
|
||||
assert summary["totalSpansCount"] == 3
|
||||
assert summary["rootServiceEntryPoint"] == "long agent run"
|
||||
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}
|
||||
assert summary["ai"]["totalCost"] == pytest.approx(0.03)
|
||||
|
||||
|
||||
def test_summary_non_ai_trace_with_missing_root(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A trace whose recorded spans all hang off an unrecorded parent reports the synthetic
|
||||
"Missing Span" root exactly as the waterfall does, and a trace without gen_ai spans has
|
||||
no `ai` block."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
resources = {"service.name": "td-summary-orphan"}
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
missing_parent_id = TraceIdGenerator.span_id()
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
duration=timedelta(seconds=2),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=missing_parent_id,
|
||||
name="SELECT users",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=missing_parent_id,
|
||||
name="publish event",
|
||||
kind=TracesKind.SPAN_KIND_PRODUCER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
|
||||
|
||||
summary = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"), timeout=10, headers=headers)
|
||||
assert summary.status_code == HTTPStatus.OK, summary.text
|
||||
summary = summary.json()["data"]
|
||||
|
||||
waterfall = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"selectedSpanId": "", "uncollapsedSpans": []},
|
||||
)
|
||||
assert waterfall.status_code == HTTPStatus.OK, waterfall.text
|
||||
waterfall = waterfall.json()["data"]
|
||||
|
||||
assert {k: summary[k] for k in WATERFALL_FIELDS} == {k: waterfall[k] for k in WATERFALL_FIELDS}
|
||||
assert summary["hasMissingSpans"] is True
|
||||
assert summary["rootServiceName"] == ""
|
||||
assert summary["rootServiceEntryPoint"] == "Missing Span"
|
||||
assert summary["totalSpansCount"] == 2
|
||||
assert "ai" not in summary
|
||||
|
||||
|
||||
def test_summary_unknown_trace(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/traces/{TraceIdGenerator.trace_id()}/summary"),
|
||||
timeout=10,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
Reference in New Issue
Block a user