mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-18 17:30:41 +01:00
Compare commits
5 Commits
ns/trace-a
...
issue_6090
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dae0b975a | ||
|
|
d1a382945c | ||
|
|
59af5e0367 | ||
|
|
8286e787b2 | ||
|
|
e504c4081e |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -67,6 +67,7 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -202,7 +202,6 @@ telemetrystore:
|
||||
max_bytes_to_read: 0
|
||||
max_result_rows: 0
|
||||
ignore_data_skipping_indices: ""
|
||||
secondary_indices_enable_bulk_filtering: false
|
||||
|
||||
##################### Prometheus #####################
|
||||
prometheus:
|
||||
|
||||
@@ -9439,6 +9439,29 @@ 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:
|
||||
@@ -9722,6 +9745,32 @@ 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:
|
||||
@@ -15460,6 +15509,66 @@ 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
|
||||
|
||||
@@ -10884,6 +10884,78 @@ 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
|
||||
@@ -12595,6 +12667,17 @@ 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,11 +4,17 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -16,6 +22,8 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -27,6 +35,26 @@ 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
|
||||
@@ -127,6 +155,108 @@ 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
|
||||
|
||||
@@ -37,12 +37,13 @@ describe('getFieldKeySuggestions', () => {
|
||||
const response = keysResponse();
|
||||
mockedAIKeys.mockResolvedValue(response);
|
||||
|
||||
const filterConfig = { searchText: 'llm' };
|
||||
const fieldKeysConfig = { searchText: 'llm' };
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await expect(
|
||||
getFieldKeySuggestions(filterConfig, 'builder_ai_query'),
|
||||
getFieldKeySuggestions(fieldKeysConfig, 'builder_ai_query', abortSignal),
|
||||
).resolves.toBe(response);
|
||||
expect(mockedAIKeys).toHaveBeenCalledWith(filterConfig);
|
||||
expect(mockedAIKeys).toHaveBeenCalledWith(fieldKeysConfig, abortSignal);
|
||||
expect(mockedGenericKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -58,15 +59,16 @@ describe('getFieldKeySuggestions', () => {
|
||||
const response = keysResponse();
|
||||
mockedGenericKeys.mockResolvedValue(response);
|
||||
|
||||
const filterConfig = {
|
||||
const fieldKeysConfig = {
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
searchText: 'svc',
|
||||
};
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await expect(
|
||||
getFieldKeySuggestions(filterConfig, builderQueryType),
|
||||
getFieldKeySuggestions(fieldKeysConfig, builderQueryType, abortSignal),
|
||||
).resolves.toBe(response);
|
||||
expect(mockedGenericKeys).toHaveBeenCalledWith(filterConfig);
|
||||
expect(mockedGenericKeys).toHaveBeenCalledWith(fieldKeysConfig, abortSignal);
|
||||
expect(mockedAIKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,12 +34,13 @@ describe('getFieldValueSuggestions', () => {
|
||||
const response = valuesResponse();
|
||||
mockedAIValues.mockResolvedValue(response);
|
||||
|
||||
const filterConfig = { name: 'gen_ai.request.model', searchText: 'gpt' };
|
||||
const fieldValuesConfig = { name: 'gen_ai.request.model', searchText: 'gpt' };
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await expect(
|
||||
getFieldValueSuggestions(filterConfig, 'builder_ai_query'),
|
||||
getFieldValueSuggestions(fieldValuesConfig, 'builder_ai_query', abortSignal),
|
||||
).resolves.toBe(response);
|
||||
expect(mockedAIValues).toHaveBeenCalledWith(filterConfig);
|
||||
expect(mockedAIValues).toHaveBeenCalledWith(fieldValuesConfig, abortSignal);
|
||||
expect(mockedGenericValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -55,16 +56,20 @@ describe('getFieldValueSuggestions', () => {
|
||||
const response = valuesResponse();
|
||||
mockedGenericValues.mockResolvedValue(response);
|
||||
|
||||
const filterConfig = {
|
||||
const fieldValuesConfig = {
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
name: 'service.name',
|
||||
searchText: 'front',
|
||||
};
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await expect(
|
||||
getFieldValueSuggestions(filterConfig, builderQueryType),
|
||||
getFieldValueSuggestions(fieldValuesConfig, builderQueryType, abortSignal),
|
||||
).resolves.toBe(response);
|
||||
expect(mockedGenericValues).toHaveBeenCalledWith(filterConfig);
|
||||
expect(mockedGenericValues).toHaveBeenCalledWith(
|
||||
fieldValuesConfig,
|
||||
abortSignal,
|
||||
);
|
||||
expect(mockedAIValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@ import { getAIObservabilityFieldsKeys } from 'api/generated/services/ai-observab
|
||||
import { getFieldsKeys } from 'api/generated/services/fields';
|
||||
import type { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
|
||||
import { FieldKeysFilterConfig, FieldKeysResponse } from './types';
|
||||
import { FieldKeysConfig, FieldKeysResponse } from './types';
|
||||
|
||||
export const getFieldKeySuggestions = (
|
||||
filterConfig: FieldKeysFilterConfig,
|
||||
fieldKeysConfig: FieldKeysConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<FieldKeysResponse> =>
|
||||
builderQueryType === 'builder_ai_query'
|
||||
? getAIObservabilityFieldsKeys(filterConfig)
|
||||
: getFieldsKeys(filterConfig);
|
||||
? getAIObservabilityFieldsKeys(fieldKeysConfig, abortSignal)
|
||||
: getFieldsKeys(fieldKeysConfig, abortSignal);
|
||||
|
||||
@@ -2,12 +2,13 @@ import { getAIObservabilityFieldsValues } from 'api/generated/services/ai-observ
|
||||
import { getFieldsValues } from 'api/generated/services/fields';
|
||||
import type { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
|
||||
import { FieldValuesFilterConfig, FieldValuesResponse } from './types';
|
||||
import { FieldValuesConfig, FieldValuesResponse } from './types';
|
||||
|
||||
export const getFieldValueSuggestions = (
|
||||
filterConfig: FieldValuesFilterConfig,
|
||||
fieldValuesConfig: FieldValuesConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<FieldValuesResponse> =>
|
||||
builderQueryType === 'builder_ai_query'
|
||||
? getAIObservabilityFieldsValues(filterConfig)
|
||||
: getFieldsValues(filterConfig);
|
||||
? getAIObservabilityFieldsValues(fieldValuesConfig, abortSignal)
|
||||
: getFieldsValues(fieldValuesConfig, abortSignal);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
GetAIObservabilityFieldsKeys200,
|
||||
GetAIObservabilityFieldsKeysParams,
|
||||
GetAIObservabilityFieldsValues200,
|
||||
GetAIObservabilityFieldsKeysParams,
|
||||
GetAIObservabilityFieldsValuesParams,
|
||||
GetFieldsKeys200,
|
||||
GetFieldsKeysParams,
|
||||
@@ -9,14 +9,19 @@ import type {
|
||||
GetFieldsValuesParams,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type FieldKeysFilterConfig =
|
||||
export type FieldKeysConfig =
|
||||
| GetFieldsKeysParams
|
||||
| GetAIObservabilityFieldsKeysParams;
|
||||
|
||||
export type FieldValuesFilterConfig =
|
||||
export type FieldValuesConfig =
|
||||
| GetFieldsValuesParams
|
||||
| GetAIObservabilityFieldsValuesParams;
|
||||
|
||||
export type FieldKeysConfigProp = Omit<
|
||||
FieldKeysConfig,
|
||||
'signal' | 'searchText'
|
||||
>;
|
||||
|
||||
export type FieldKeysResponse =
|
||||
| GetFieldsKeys200
|
||||
| GetAIObservabilityFieldsKeys200;
|
||||
|
||||
@@ -6,7 +6,8 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
|
||||
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import AddedFields from './AddedFields';
|
||||
@@ -31,6 +32,9 @@ interface FieldsSelectorProps {
|
||||
// Lets users add a free-typed field which
|
||||
// does not show up in the suggestions
|
||||
allowCustomFields?: boolean;
|
||||
fieldKeysConfig?: FieldKeysConfigProp;
|
||||
builderQueryType?: BuilderQueryType;
|
||||
extraFields?: TelemetryFieldKey[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
@@ -50,6 +54,9 @@ function FieldsSelectorContent({
|
||||
maxFields,
|
||||
requiredFields,
|
||||
allowCustomFields,
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
extraFields,
|
||||
width = DEFAULT_PANEL_WIDTH,
|
||||
height,
|
||||
defaultPosition,
|
||||
@@ -158,6 +165,9 @@ function FieldsSelectorContent({
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
allowCustomFields={allowCustomFields}
|
||||
fieldKeysConfig={fieldKeysConfig}
|
||||
builderQueryType={builderQueryType}
|
||||
extraFields={extraFields}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
|
||||
@@ -3,18 +3,22 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import {
|
||||
BuilderQueryType,
|
||||
FieldContext,
|
||||
SignalType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
|
||||
import { mergeExtraFields } from 'utils/extraFields';
|
||||
|
||||
import styles from './FieldsSelector.module.scss';
|
||||
|
||||
const EMPTY_EXTRA_FIELDS: TelemetryFieldKey[] = [];
|
||||
|
||||
interface OtherFieldsProps {
|
||||
signal: DataSource;
|
||||
debouncedInputValue: string;
|
||||
@@ -22,6 +26,9 @@ interface OtherFieldsProps {
|
||||
onAdd: (field: TelemetryFieldKey) => void;
|
||||
isAtLimit: boolean;
|
||||
allowCustomFields?: boolean;
|
||||
fieldKeysConfig?: FieldKeysConfigProp;
|
||||
builderQueryType?: BuilderQueryType;
|
||||
extraFields?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
function OtherFields({
|
||||
@@ -31,26 +38,26 @@ function OtherFields({
|
||||
onAdd,
|
||||
isAtLimit,
|
||||
allowCustomFields,
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
extraFields = EMPTY_EXTRA_FIELDS,
|
||||
}: OtherFieldsProps): JSX.Element {
|
||||
const { data, isFetching } = useGetQueryKeySuggestions(
|
||||
const { data: fetchedFields, isFetching } = useFieldKeysSuggestion(
|
||||
{
|
||||
signal,
|
||||
...fieldKeysConfig,
|
||||
signal: DATA_SOURCE_TO_SIGNAL[signal],
|
||||
searchText: debouncedInputValue,
|
||||
},
|
||||
{
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FIELDS_SELECTOR_SUGGESTIONS,
|
||||
signal,
|
||||
debouncedInputValue,
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
builderQueryType,
|
||||
);
|
||||
|
||||
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
const search = debouncedInputValue.trim().toLowerCase();
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
const suggestions: TelemetryFieldKey[] = mergeExtraFields(
|
||||
extraFields.filter((field) => field.name.toLowerCase().includes(search)),
|
||||
fetchedFields ?? [],
|
||||
).map((attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
|
||||
signal: attr.signal as SignalType,
|
||||
@@ -87,7 +94,13 @@ function OtherFields({
|
||||
key: buildCompositeKey(typed, ''),
|
||||
};
|
||||
return [customField, ...available];
|
||||
}, [data, addedFields, allowCustomFields, debouncedInputValue]);
|
||||
}, [
|
||||
extraFields,
|
||||
fetchedFields,
|
||||
addedFields,
|
||||
allowCustomFields,
|
||||
debouncedInputValue,
|
||||
]);
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { act, fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSelector from '../FieldsSelector';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
|
||||
useFieldKeysSuggestion: jest.fn(() => ({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
@@ -21,22 +27,15 @@ jest.mock('periscope/components/FloatingPanel', () => ({
|
||||
}));
|
||||
|
||||
const mockSuggestions = (names: string[]): void => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
|
||||
data: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import OtherFields from '../OtherFields';
|
||||
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
|
||||
useFieldKeysSuggestion: jest.fn(() => ({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSuggestions = (names: string[]): void => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
|
||||
data: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -82,7 +83,6 @@ describe('OtherFields — custom (free-typed) option', () => {
|
||||
mockSuggestions(['orderId']);
|
||||
renderOtherFields({ debouncedInputValue: 'orderid' });
|
||||
|
||||
// the real suggestion shows, the lowercased custom name does not
|
||||
expect(screen.getByText('orderId')).toBeInTheDocument();
|
||||
expect(screen.queryByText('orderid')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -116,10 +116,126 @@ describe('OtherFields — custom (free-typed) option', () => {
|
||||
it('shows the custom option at the field limit but hides its Add button', () => {
|
||||
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c', isAtLimit: true });
|
||||
|
||||
// same as every other row at the limit: name shown, no Add button
|
||||
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /add/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OtherFields — field keys config', () => {
|
||||
const pool: TelemetryFieldKey[] = [
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
{ name: 'llm_call_count', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
];
|
||||
|
||||
const fieldKeysConfig: FieldKeysConfigProp = {
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
};
|
||||
const builderQueryType: BuilderQueryType = 'builder_ai_query';
|
||||
|
||||
const mockPool = (fields: TelemetryFieldKey[]): void => {
|
||||
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
|
||||
data: fields,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPool(pool);
|
||||
});
|
||||
|
||||
it('lists the pool it is handed', () => {
|
||||
renderOtherFields({
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
allowCustomFields: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText('total_tokens')).toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards the fetch params and search to the shared keys hook', () => {
|
||||
renderOtherFields({
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
allowCustomFields: false,
|
||||
debouncedInputValue: 'llm',
|
||||
});
|
||||
|
||||
expect(useFieldKeysSuggestion).toHaveBeenCalledWith(
|
||||
{
|
||||
...fieldKeysConfig,
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.LOGS],
|
||||
searchText: 'llm',
|
||||
},
|
||||
builderQueryType,
|
||||
);
|
||||
});
|
||||
|
||||
it('lists extra fields the keys endpoint never returns', () => {
|
||||
mockPool([{ name: 'total_tokens' } as TelemetryFieldKey]);
|
||||
|
||||
renderOtherFields({
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
extraFields: [{ name: 'last_activity_time' } as TelemetryFieldKey],
|
||||
allowCustomFields: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText('last_activity_time')).toBeInTheDocument();
|
||||
expect(screen.getByText('total_tokens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters extra fields by search text', () => {
|
||||
mockPool([]);
|
||||
|
||||
renderOtherFields({
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
extraFields: [
|
||||
{ name: 'last_activity_time' } as TelemetryFieldKey,
|
||||
{ name: 'timestamp' } as TelemetryFieldKey,
|
||||
],
|
||||
debouncedInputValue: 'activity',
|
||||
allowCustomFields: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText('last_activity_time')).toBeInTheDocument();
|
||||
expect(screen.queryByText('timestamp')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a fetched key whose name does not contain the search text', () => {
|
||||
mockPool([
|
||||
{ name: 'service.name', fieldContext: 'resource' } as TelemetryFieldKey,
|
||||
]);
|
||||
|
||||
renderOtherFields({
|
||||
debouncedInputValue: 'resource.service',
|
||||
allowCustomFields: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits pool fields that are already added', () => {
|
||||
renderOtherFields({
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
allowCustomFields: false,
|
||||
addedFields: [
|
||||
{
|
||||
name: 'total_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
key: 'trace:total_tokens:float64',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.queryByText('total_tokens')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import './ListViewOrderBy.styles.scss';
|
||||
|
||||
const DEFAULT_EXTRA_FIELDS: TelemetryFieldKey[] = [
|
||||
{ name: 'timestamp' } as TelemetryFieldKey,
|
||||
];
|
||||
|
||||
interface ListViewOrderByProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
dataSource: DataSource;
|
||||
fieldKeysConfig?: FieldKeysConfigProp;
|
||||
builderQueryType?: BuilderQueryType;
|
||||
extraFields?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
// Loader component for the dropdown when loading or no results
|
||||
@@ -26,6 +33,9 @@ function ListViewOrderBy({
|
||||
value,
|
||||
onChange,
|
||||
dataSource,
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
extraFields = DEFAULT_EXTRA_FIELDS,
|
||||
}: ListViewOrderByProps): JSX.Element {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedInput, setDebouncedInput] = useState('');
|
||||
@@ -34,17 +44,14 @@ function ListViewOrderBy({
|
||||
>([]);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Fetch key suggestions based on debounced input
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['orderByKeySuggestions', dataSource, debouncedInput],
|
||||
queryFn: async () => {
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText: debouncedInput,
|
||||
});
|
||||
return response.data;
|
||||
const { data, isLoading } = useFieldKeysSuggestion(
|
||||
{
|
||||
...fieldKeysConfig,
|
||||
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
|
||||
searchText: debouncedInput,
|
||||
},
|
||||
});
|
||||
builderQueryType,
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
@@ -55,24 +62,24 @@ function ListViewOrderBy({
|
||||
[],
|
||||
);
|
||||
|
||||
const extraKeysSignature = extraFields.map((field) => field.name).join(',');
|
||||
|
||||
// Update options when API data changes
|
||||
useEffect(() => {
|
||||
const rawKeys: QueryKeyDataSuggestionsProps[] = data?.data?.keys
|
||||
? Object.values(data.data?.keys).flat()
|
||||
: [];
|
||||
const keyNames = (data ?? []).map((field) => field.name);
|
||||
const search = searchInput.trim().toLowerCase();
|
||||
const extraMatches = extraKeysSignature
|
||||
.split(',')
|
||||
.filter((key) => key.length > 0 && key.toLowerCase().includes(search));
|
||||
const uniqueKeys = [...new Set([...extraMatches, ...keyNames])];
|
||||
|
||||
const keyNames = rawKeys.map((key) => key.name);
|
||||
const uniqueKeys = [
|
||||
...new Set(searchInput ? keyNames : ['timestamp', ...keyNames]),
|
||||
];
|
||||
|
||||
const updatedOptions = uniqueKeys.flatMap((key) => [
|
||||
{ label: `${key} (desc)`, value: `${key}:desc` },
|
||||
{ label: `${key} (asc)`, value: `${key}:asc` },
|
||||
]);
|
||||
|
||||
setSelectOptions(updatedOptions);
|
||||
}, [data, searchInput]);
|
||||
setSelectOptions(
|
||||
uniqueKeys.flatMap((key) => [
|
||||
{ label: `${key} (desc)`, value: `${key}:desc` },
|
||||
{ label: `${key} (asc)`, value: `${key}:asc` },
|
||||
]),
|
||||
);
|
||||
}, [data, searchInput, extraKeysSignature]);
|
||||
|
||||
// Handle search input with debounce
|
||||
const handleSearch = (input: string): void => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import {
|
||||
TRACE_VIEW_BUILDER_QUERY_TYPE,
|
||||
TRACE_VIEW_FIELD_KEYS,
|
||||
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
|
||||
} from 'container/LLMObservability/Explorer/constants';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import ListViewOrderBy from '../ListViewOrderBy';
|
||||
|
||||
const seenAI: URLSearchParams[] = [];
|
||||
const seenGeneric: URLSearchParams[] = [];
|
||||
|
||||
const mockAIKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(req, res, ctx) => {
|
||||
seenAI.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mockGenericKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (req, res, ctx) => {
|
||||
seenGeneric.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const openDropdown = (): void => {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
};
|
||||
|
||||
const getOptionLabels = (): string[] =>
|
||||
Array.from(document.querySelectorAll('.ant-select-item-option-content')).map(
|
||||
(node) => node.textContent ?? '',
|
||||
);
|
||||
|
||||
describe('ListViewOrderBy', () => {
|
||||
beforeEach(() => {
|
||||
seenAI.length = 0;
|
||||
seenGeneric.length = 0;
|
||||
});
|
||||
|
||||
it('reads the ai_observability trace context for an AI query', async () => {
|
||||
mockAIKeys(['total_tokens']);
|
||||
|
||||
render(
|
||||
<ListViewOrderBy
|
||||
value="last_activity_time:desc"
|
||||
onChange={jest.fn()}
|
||||
dataSource={DataSource.TRACES}
|
||||
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
|
||||
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
|
||||
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(seenAI).toHaveLength(1);
|
||||
});
|
||||
expect(seenAI[0]?.get('searchText')).toBe('');
|
||||
expect(seenAI[0]?.get('fieldContext')).toBe(
|
||||
TelemetrytypesFieldContextDTO.trace,
|
||||
);
|
||||
expect(seenGeneric).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('offers the extra keys alongside the ones the endpoint reports', async () => {
|
||||
mockAIKeys(['total_tokens']);
|
||||
|
||||
render(
|
||||
<ListViewOrderBy
|
||||
value="last_activity_time:desc"
|
||||
onChange={jest.fn()}
|
||||
dataSource={DataSource.TRACES}
|
||||
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
|
||||
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
|
||||
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOptionLabels()).toContain('total_tokens (desc)');
|
||||
});
|
||||
expect(getOptionLabels()).toContain('last_activity_time (asc)');
|
||||
});
|
||||
|
||||
it('keeps a matching extra key while searching', async () => {
|
||||
mockAIKeys([]);
|
||||
|
||||
render(
|
||||
<ListViewOrderBy
|
||||
value="last_activity_time:desc"
|
||||
onChange={jest.fn()}
|
||||
dataSource={DataSource.TRACES}
|
||||
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
|
||||
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
|
||||
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(seenAI.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
openDropdown();
|
||||
fireEvent.change(screen.getByRole('combobox'), {
|
||||
target: { value: 'activity' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOptionLabels()).toContain('last_activity_time (desc)');
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to timestamp and the generic endpoint', async () => {
|
||||
mockGenericKeys(['service.name']);
|
||||
|
||||
render(
|
||||
<ListViewOrderBy
|
||||
value="timestamp:desc"
|
||||
onChange={jest.fn()}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(seenGeneric).toHaveLength(1);
|
||||
});
|
||||
expect(seenGeneric[0]?.get('signal')).toBe(DataSource.TRACES);
|
||||
expect(seenGeneric[0]?.get('searchText')).toBe('');
|
||||
|
||||
openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOptionLabels()).toContain('timestamp (desc)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ export enum LOCALSTORAGE {
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -106,8 +106,8 @@ export const REACT_QUERY_KEY = {
|
||||
// Dashboard Grid Card Query Keys
|
||||
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
|
||||
|
||||
// Fields Selector Query Keys
|
||||
GET_FIELDS_SELECTOR_SUGGESTIONS: 'GET_FIELDS_SELECTOR_SUGGESTIONS',
|
||||
// Field Keys Suggestion Query Keys
|
||||
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
|
||||
|
||||
// AI Assistant Query Keys
|
||||
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
@@ -14,7 +16,10 @@ function TraceExplorerControls({
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
extraFields,
|
||||
requiredFields,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
@@ -44,6 +49,10 @@ function TraceExplorerControls({
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
fieldKeysConfig={fieldKeysConfig}
|
||||
builderQueryType={builderQueryType}
|
||||
extraFields={extraFields}
|
||||
requiredFields={requiredFields}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -57,26 +66,28 @@ function TraceExplorerControls({
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
fieldKeysConfig?: FieldKeysConfigProp;
|
||||
builderQueryType?: BuilderQueryType;
|
||||
extraFields?: TelemetryFieldKey[];
|
||||
requiredFields?: readonly string[];
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
showSizeChanger: true,
|
||||
config: null,
|
||||
fieldKeysConfig: undefined,
|
||||
builderQueryType: undefined,
|
||||
extraFields: undefined,
|
||||
requiredFields: undefined,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
|
||||
@@ -10,6 +10,25 @@
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.orderByContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.orderByLabel {
|
||||
color: var(--muted-foreground);
|
||||
// Between --periscope-font-size-small (11px) and -base (13px), so literal.
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -3,35 +3,45 @@ import {
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { getTraceLink } from '../ListView/utils';
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import TracesTable from '../TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import {
|
||||
TRACE_VIEW_BUILDER_QUERY_TYPE,
|
||||
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
|
||||
TRACE_VIEW_DEFAULT_ORDER_BY,
|
||||
TRACE_VIEW_FIELD_KEYS,
|
||||
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
|
||||
} from '../constants';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { PER_PAGE_OPTIONS } from './configs';
|
||||
import { useTraceViewColumns } from './useTraceViewColumns';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
@@ -49,6 +59,16 @@ function TracesView({
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
|
||||
|
||||
const {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
requiredFields,
|
||||
isLoading: isColumnsLoading,
|
||||
} = useTraceViewColumns();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
@@ -60,8 +80,8 @@ function TracesView({
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
|
||||
[stagedQuery],
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
@@ -73,6 +93,7 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
@@ -81,6 +102,7 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -142,27 +164,43 @@ function TracesView({
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
|
||||
const handleOrderChange = useCallback((value: string): void => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const fieldsSelectorConfig = useMemo(
|
||||
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
|
||||
[selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
<div className={styles.orderByContainer}>
|
||||
<div className={styles.orderByLabel}>
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={handleOrderChange}
|
||||
dataSource={DataSource.TRACES}
|
||||
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
|
||||
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
|
||||
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
config={fieldsSelectorConfig}
|
||||
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
|
||||
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
|
||||
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
|
||||
requiredFields={requiredFields}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,10 +208,11 @@ function TracesView({
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isColumnsLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useTraceViewColumns } from '../useTraceViewColumns';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
const AGGREGATE_KEYS = [
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'distinct_tool_count',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
'max_llm_duration_nano',
|
||||
];
|
||||
|
||||
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
|
||||
fields.map((field) => field.name);
|
||||
|
||||
const columnNames = (columns: { header?: unknown }[]): string[] =>
|
||||
columns.map((column) => column.header as string);
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const seenAI: URLSearchParams[] = [];
|
||||
|
||||
const mockAggregateKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(req, res, ctx) => {
|
||||
seenAI.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
[
|
||||
{
|
||||
name,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderColumns = async (): Promise<
|
||||
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
|
||||
> => {
|
||||
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(rendered.result.current.isLoading).toBe(false);
|
||||
});
|
||||
return rendered;
|
||||
};
|
||||
|
||||
describe('useTraceViewColumns', () => {
|
||||
beforeEach(() => {
|
||||
seenAI.length = 0;
|
||||
useColumnStore.getState().tables = {};
|
||||
localStorage.clear();
|
||||
mockAggregateKeys(AGGREGATE_KEYS);
|
||||
});
|
||||
|
||||
it('reads the aggregates from the trace context of the keys endpoint', async () => {
|
||||
await renderColumns();
|
||||
|
||||
expect(seenAI).toHaveLength(1);
|
||||
expect(seenAI[0]?.get('searchText')).toBe('');
|
||||
expect(seenAI[0]?.get('fieldContext')).toBe(
|
||||
TelemetrytypesFieldContextDTO.trace,
|
||||
);
|
||||
});
|
||||
|
||||
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(columnNames(result.current.columns)).toStrictEqual([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'trace_id',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
...AGGREGATE_KEYS,
|
||||
]);
|
||||
});
|
||||
|
||||
it('selects only the default-visible columns on first render', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'trace_id',
|
||||
'llm_call_count',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a newly reported aggregate hidden until it is picked', async () => {
|
||||
mockAggregateKeys(['brand_new_aggregate']);
|
||||
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
|
||||
expect(fieldNames(result.current.selectedFields)).not.toContain(
|
||||
'brand_new_aggregate',
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the columns dropped from the selection', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'total_tokens',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a column added back from the pool', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }]);
|
||||
});
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'input',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the trace id column even when the selection drops it', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'span_count' }]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
|
||||
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
|
||||
});
|
||||
|
||||
it('persists the selection order', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'total_tokens',
|
||||
'trace_id',
|
||||
'service.name',
|
||||
]);
|
||||
expect(
|
||||
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
|
||||
).toStrictEqual([
|
||||
'trace:total_tokens:float64',
|
||||
'trace_id',
|
||||
'resource:service.name',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -5,18 +5,29 @@ import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name' },
|
||||
{ name: 'duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
] as TelemetryFieldKey[];
|
||||
/** Always visible: it is the row's link to the trace. */
|
||||
export const TRACE_ID_COLUMN_ID = 'trace_id';
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
|
||||
const DEFAULT_VISIBLE_FIELDS = new Set([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'llm_call_count',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
TRACE_ID_COLUMN_ID,
|
||||
]);
|
||||
|
||||
export const buildTraceViewColumns = (
|
||||
fields: TelemetryFieldKey[],
|
||||
): TableColumnDef<TracesTableRow>[] =>
|
||||
fields.map((field) => ({
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
);
|
||||
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
|
||||
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
|
||||
enableMove: field.name !== TRACE_ID_COLUMN_ID,
|
||||
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
|
||||
canBeHidden: field.name !== TRACE_ID_COLUMN_ID,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { mergeExtraFields } from 'utils/extraFields';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
hideColumn,
|
||||
initializeFromDefaults,
|
||||
setColumnOrder,
|
||||
showColumn,
|
||||
useColumnOrder,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
TRACE_VIEW_BUILDER_QUERY_TYPE,
|
||||
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
|
||||
TRACE_VIEW_FIELD_KEYS,
|
||||
} from '../constants';
|
||||
import { buildTraceViewColumns, TRACE_ID_COLUMN_ID } from './configs';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
|
||||
const columnIdOf = (field: TelemetryFieldKey): string =>
|
||||
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
|
||||
|
||||
interface UseTraceViewColumns {
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
selectedFields: TelemetryFieldKey[];
|
||||
onFieldsChange: (next: TelemetryFieldKey[]) => void;
|
||||
requiredFields: readonly string[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
|
||||
export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
|
||||
{
|
||||
...TRACE_VIEW_FIELD_KEYS,
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
|
||||
searchText: '',
|
||||
},
|
||||
TRACE_VIEW_BUILDER_QUERY_TYPE,
|
||||
);
|
||||
|
||||
const availableFields = useMemo(
|
||||
() => mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
|
||||
[fetchedFields],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildTraceViewColumns(availableFields),
|
||||
[availableFields],
|
||||
);
|
||||
|
||||
// Defaults from a partial column set would persist as the user's own choice.
|
||||
useEffect(() => {
|
||||
if (isFetched) {
|
||||
initializeFromDefaults(STORAGE_KEY, columns);
|
||||
}
|
||||
}, [isFetched, columns]);
|
||||
|
||||
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
|
||||
const columnOrder = useColumnOrder(STORAGE_KEY);
|
||||
|
||||
const selectedFields = useMemo(() => {
|
||||
const hidden = new Set(hiddenColumnIds);
|
||||
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
|
||||
|
||||
return availableFields
|
||||
.filter((field) => !hidden.has(columnIdOf(field)))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
|
||||
(orderIndex.get(columnIdOf(b)) ?? Infinity),
|
||||
);
|
||||
}, [availableFields, hiddenColumnIds, columnOrder]);
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(next: TelemetryFieldKey[]): void => {
|
||||
const keptIds = new Set(next.map(columnIdOf));
|
||||
|
||||
columns.forEach((column) => {
|
||||
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
|
||||
showColumn(STORAGE_KEY, column.id);
|
||||
} else {
|
||||
hideColumn(STORAGE_KEY, column.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Columns missing from the order sort last, so the visible ones suffice.
|
||||
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
|
||||
},
|
||||
[columns],
|
||||
);
|
||||
|
||||
return {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
requiredFields: [TRACE_ID_COLUMN_ID],
|
||||
isLoading: !isFetched,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
list: {
|
||||
name: 'list',
|
||||
@@ -34,3 +37,29 @@ export const TOOLBAR_VIEWS = {
|
||||
key: 'clickhouse',
|
||||
},
|
||||
};
|
||||
|
||||
export const TRACE_VIEW_DEFAULT_ORDER_BY = 'last_activity_time:desc';
|
||||
|
||||
/** Display-only: ordering or filtering on one is an error, so the keys endpoint omits them. */
|
||||
export const TRACE_VIEW_COLUMN_EXTRA_FIELDS: TelemetryFieldKey[] = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'root_span_name' },
|
||||
{ name: 'trace_duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'start_time' },
|
||||
{ name: 'end_time' },
|
||||
{ name: 'error_count' },
|
||||
{ name: 'input' },
|
||||
{ name: 'output' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
export const TRACE_VIEW_FIELD_KEYS = {
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
} as const;
|
||||
|
||||
export const TRACE_VIEW_BUILDER_QUERY_TYPE = 'builder_ai_query' as const;
|
||||
|
||||
export const TRACE_VIEW_ORDER_BY_EXTRA_FIELDS: TelemetryFieldKey[] = [
|
||||
{ name: 'last_activity_time' } as TelemetryFieldKey,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldKeysConfig, FieldKeysResponse } from 'api/querySuggestions/types';
|
||||
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
getFieldKeysQueryOptions,
|
||||
toFieldKeys,
|
||||
} from '../useFieldKeysSuggestion';
|
||||
|
||||
/** Drives the options object the way react-query does, without a client. */
|
||||
const fetchKeys = async (
|
||||
fieldKeysConfig: FieldKeysConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
): Promise<TelemetryFieldKey[]> => {
|
||||
const { queryFn, select } = getFieldKeysQueryOptions(
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
);
|
||||
const response = await (
|
||||
queryFn as (context: { signal: AbortSignal }) => Promise<FieldKeysResponse>
|
||||
)({ signal: new AbortController().signal });
|
||||
|
||||
return select?.(response) ?? [];
|
||||
};
|
||||
|
||||
const mockKeys = (
|
||||
path: '/api/v1/ai_observability/fields/keys' | '/api/v1/fields/keys',
|
||||
names: string[],
|
||||
onRequest?: (params: URLSearchParams) => void,
|
||||
): void => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}${path}`, (req, res, ctx) => {
|
||||
onRequest?.(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe('useFieldKeysSuggestion', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
const seen: URLSearchParams[] = [];
|
||||
mockKeys(
|
||||
'/api/v1/ai_observability/fields/keys',
|
||||
['total_tokens'],
|
||||
(params) => {
|
||||
seen.push(params);
|
||||
},
|
||||
);
|
||||
|
||||
const keys = await fetchKeys(
|
||||
{
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
|
||||
searchText: 'llm',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
},
|
||||
'builder_ai_query',
|
||||
);
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.get('searchText')).toBe('llm');
|
||||
expect(seen[0]?.get('fieldContext')).toBe(
|
||||
TelemetrytypesFieldContextDTO.trace,
|
||||
);
|
||||
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
|
||||
});
|
||||
|
||||
it('reads the generic endpoint for an unmarked query', async () => {
|
||||
const seen: URLSearchParams[] = [];
|
||||
mockKeys('/api/v1/fields/keys', ['service.name'], (params) => {
|
||||
seen.push(params);
|
||||
});
|
||||
|
||||
const keys = await fetchKeys({
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
|
||||
searchText: 'svc',
|
||||
});
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.get('signal')).toBe(DataSource.TRACES);
|
||||
expect(seen[0]?.get('searchText')).toBe('svc');
|
||||
expect(keys.map((key) => key.name)).toStrictEqual(['service.name']);
|
||||
});
|
||||
|
||||
it('reads the trace context of the ai_observability endpoint', async () => {
|
||||
const seen: URLSearchParams[] = [];
|
||||
mockKeys(
|
||||
'/api/v1/ai_observability/fields/keys',
|
||||
['total_tokens'],
|
||||
(params) => {
|
||||
seen.push(params);
|
||||
},
|
||||
);
|
||||
|
||||
const keys = await fetchKeys(
|
||||
{
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
|
||||
searchText: '',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
},
|
||||
'builder_ai_query',
|
||||
);
|
||||
|
||||
expect(seen[0]?.get('searchText')).toBe('');
|
||||
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
|
||||
});
|
||||
|
||||
it('reuses the cached keys response for a second empty search', async () => {
|
||||
const seen: URLSearchParams[] = [];
|
||||
mockKeys(
|
||||
'/api/v1/ai_observability/fields/keys',
|
||||
['total_tokens'],
|
||||
(params) => {
|
||||
seen.push(params);
|
||||
},
|
||||
);
|
||||
|
||||
const fieldKeysConfig = {
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
|
||||
searchText: '',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
};
|
||||
|
||||
// Built twice: equal keys must resolve to one cache entry, not two requests.
|
||||
await queryClient.fetchQuery(
|
||||
getFieldKeysQueryOptions(fieldKeysConfig, 'builder_ai_query'),
|
||||
);
|
||||
await queryClient.fetchQuery(
|
||||
getFieldKeysQueryOptions(fieldKeysConfig, 'builder_ai_query'),
|
||||
);
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hands the query signal to the fetcher so a superseded search aborts', async () => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_req, res, ctx) =>
|
||||
res(ctx.delay(500), ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const { queryFn } = getFieldKeysQueryOptions({
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.LOGS],
|
||||
searchText: 'svc',
|
||||
});
|
||||
const pending = (
|
||||
queryFn as (context: { signal: AbortSignal }) => Promise<unknown>
|
||||
)({ signal: controller.signal });
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('treats a null keys map as empty', () => {
|
||||
expect(
|
||||
toFieldKeys({
|
||||
status: 'success',
|
||||
data: { complete: false, keys: null },
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
QueryKey,
|
||||
useQuery,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { FieldKeysConfig, FieldKeysResponse } from 'api/querySuggestions/types';
|
||||
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export type FieldKeysQueryOptions = UseQueryOptions<
|
||||
FieldKeysResponse,
|
||||
ErrorType<RenderErrorResponseDTO>,
|
||||
TelemetryFieldKey[]
|
||||
> & { queryKey: QueryKey };
|
||||
|
||||
export const toFieldKeys = (
|
||||
res: FieldKeysResponse | undefined,
|
||||
): TelemetryFieldKey[] =>
|
||||
Object.values(res?.data?.keys ?? {})
|
||||
.flat()
|
||||
.map((key) => ({ ...key }) as TelemetryFieldKey);
|
||||
|
||||
export const getFieldKeysQueryOptions = (
|
||||
fieldKeysConfig: FieldKeysConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
): FieldKeysQueryOptions => ({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.FIELD_KEYS_SUGGESTION,
|
||||
builderQueryType,
|
||||
fieldKeysConfig,
|
||||
],
|
||||
queryFn: ({ signal }): Promise<FieldKeysResponse> =>
|
||||
getFieldKeySuggestions(fieldKeysConfig, builderQueryType, signal),
|
||||
select: toFieldKeys,
|
||||
staleTime: FIELD_API_CACHE_TIME,
|
||||
cacheTime: FIELD_API_CACHE_TIME,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
export const useFieldKeysSuggestion = (
|
||||
fieldKeysConfig: FieldKeysConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
|
||||
useQuery(getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType));
|
||||
44
frontend/src/utils/__tests__/extraFields.test.ts
Normal file
44
frontend/src/utils/__tests__/extraFields.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { mergeExtraFields } from '../extraFields';
|
||||
|
||||
describe('mergeExtraFields', () => {
|
||||
it('drops fetched keys that share a composite key with an extra field', () => {
|
||||
expect(
|
||||
mergeExtraFields(
|
||||
[{ name: 'trace_id' } as TelemetryFieldKey],
|
||||
[
|
||||
{ name: 'trace_id' } as TelemetryFieldKey,
|
||||
{ name: 'total_tokens' } as TelemetryFieldKey,
|
||||
],
|
||||
).map((key) => key.name),
|
||||
).toStrictEqual(['trace_id', 'total_tokens']);
|
||||
});
|
||||
|
||||
it('keeps extra and fetched keys that share a name but differ in context', () => {
|
||||
expect(
|
||||
mergeExtraFields(
|
||||
[{ name: 'service.name', fieldContext: 'resource' } as TelemetryFieldKey],
|
||||
[
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'attribute',
|
||||
} as TelemetryFieldKey,
|
||||
{ name: 'total_tokens' } as TelemetryFieldKey,
|
||||
],
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'service.name', fieldContext: 'attribute' },
|
||||
{ name: 'total_tokens' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the fetched keys when there are no extra fields', () => {
|
||||
expect(
|
||||
mergeExtraFields(undefined, [
|
||||
{ name: 'total_tokens' } as TelemetryFieldKey,
|
||||
]).map((key) => key.name),
|
||||
).toStrictEqual(['total_tokens']);
|
||||
});
|
||||
});
|
||||
23
frontend/src/utils/extraFields.ts
Normal file
23
frontend/src/utils/extraFields.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export const mergeExtraFields = (
|
||||
extra: TelemetryFieldKey[] = [],
|
||||
fetched: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] => {
|
||||
const extraKeys = new Set(
|
||||
extra.map((field) =>
|
||||
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
...extra,
|
||||
...fetched.filter(
|
||||
(field) =>
|
||||
!extraKeys.has(
|
||||
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType),
|
||||
),
|
||||
),
|
||||
];
|
||||
};
|
||||
@@ -10,6 +10,23 @@ 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,7 +6,9 @@ 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"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,27 @@ 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,6 +8,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -39,6 +40,21 @@ 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,9 +10,15 @@ 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 $$.
|
||||
@@ -38,10 +44,18 @@ type spanDurationRow struct {
|
||||
|
||||
type traceStore struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
storage qbtypes.Storage
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore) *traceStore {
|
||||
return &traceStore{telemetryStore: ts}
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, fl flagger.Flagger) *traceStore {
|
||||
return &traceStore{
|
||||
telemetryStore: ts,
|
||||
metadataStore: metadataStore,
|
||||
storage: tracestelemetryschema.NewStorage(),
|
||||
flagger: fl,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*spantypes.TraceSummary, error) {
|
||||
@@ -65,6 +79,131 @@ 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,10 +6,12 @@ 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)
|
||||
@@ -17,6 +19,7 @@ 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,
|
||||
serviceNameT1,
|
||||
spanNameT1,
|
||||
serviceNameT2,
|
||||
spanNameT2,
|
||||
clickhousesql.StringLiteral(serviceNameT1),
|
||||
clickhousesql.StringLiteral(spanNameT1),
|
||||
clickhousesql.StringLiteral(serviceNameT2),
|
||||
clickhousesql.StringLiteral(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,
|
||||
serviceNameT1,
|
||||
spanNameT1,
|
||||
serviceNameT2,
|
||||
spanNameT2,
|
||||
clickhousesql.StringLiteral(serviceNameT1),
|
||||
clickhousesql.StringLiteral(spanNameT1),
|
||||
clickhousesql.StringLiteral(serviceNameT2),
|
||||
clickhousesql.StringLiteral(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), providerSettings, config.TraceDetail),
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
|
||||
@@ -19,6 +19,7 @@ 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,7 +46,6 @@ 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,10 +72,6 @@ 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,6 +15,7 @@ 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, which is not supported", channelName)
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth with fields other than username and password, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1026,8 +1026,8 @@ func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commonc
|
||||
}
|
||||
|
||||
authorization := httpConfig.Authorization
|
||||
if *authorization != (commoncfg.Authorization{Type: bearerAuthorizationType, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", channelName)
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -542,3 +542,42 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ 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)
|
||||
|
||||
76
pkg/types/spantypes/trace_summary.go
Normal file
76
pkg/types/spantypes/trace_summary.go
Normal file
@@ -0,0 +1,76 @@
|
||||
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,6 +895,7 @@ _TRACES_TABLES_TO_TRUNCATE = [
|
||||
"span_attributes_keys",
|
||||
"signoz_error_index_v2",
|
||||
"top_level_operations",
|
||||
"trace_summary",
|
||||
]
|
||||
|
||||
|
||||
|
||||
280
tests/integration/tests/tracedetail/01_summary.py
Normal file
280
tests/integration/tests/tracedetail/01_summary.py
Normal file
@@ -0,0 +1,280 @@
|
||||
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