mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-06 18:20:50 +01:00
Compare commits
8 Commits
feat/ai-tr
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
084d9aad91 | ||
|
|
2466aa166a | ||
|
|
82a588c15b | ||
|
|
13be8de648 | ||
|
|
58bb656a11 | ||
|
|
f6f95d4f7c | ||
|
|
528cb41551 | ||
|
|
185677634c |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -68,7 +68,6 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -9693,19 +9693,6 @@ components:
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesGettableTraceThread:
|
||||
properties:
|
||||
nextCursor:
|
||||
type: string
|
||||
prevCursor:
|
||||
type: string
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesThreadSpan'
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -10020,84 +10007,6 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesThreadSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
db_name:
|
||||
type: string
|
||||
db_operation:
|
||||
type: string
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
nullable: true
|
||||
type: array
|
||||
external_http_method:
|
||||
type: string
|
||||
external_http_url:
|
||||
type: string
|
||||
flags:
|
||||
minimum: 0
|
||||
type: integer
|
||||
has_children:
|
||||
type: boolean
|
||||
has_error:
|
||||
type: boolean
|
||||
http_host:
|
||||
type: string
|
||||
http_method:
|
||||
type: string
|
||||
http_url:
|
||||
type: string
|
||||
is_remote:
|
||||
type: string
|
||||
kind_string:
|
||||
type: string
|
||||
level:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
response_status_code:
|
||||
type: string
|
||||
span_id:
|
||||
type: string
|
||||
status_code:
|
||||
type: integer
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
sub_tree_node_count:
|
||||
minimum: 0
|
||||
type: integer
|
||||
time_unix:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
required:
|
||||
- references
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -15822,89 +15731,6 @@ paths:
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the spans carrying gen_ai input or output messages in timestamp
|
||||
order. Pass nextCursor as after or prevCursor as before to page, or spanId
|
||||
to open the page around a span.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: after
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: before
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: spanId
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceThread'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"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 thread view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
|
||||
@@ -11209,161 +11209,6 @@ export interface SpantypesOtelSpanRefDTO {
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesThreadSpanDTOAttributesAnyOf = { [key: string]: unknown };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesThreadSpanDTOAttributes =
|
||||
SpantypesThreadSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesThreadSpanDTOResourceAnyOf = { [key: string]: string };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesThreadSpanDTOResource =
|
||||
SpantypesThreadSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesThreadSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesThreadSpanDTOAttributes;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_operation?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
events?: SpantypesEventDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_url?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
flags?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_children?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_host?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
is_remote?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
level?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesThreadSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
response_status_code?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
status_code?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
sub_tree_node_count?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
time_unix?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_state?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceThreadDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nextCursor?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
prevCursor?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
spans: SpantypesThreadSpanDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -13037,40 +12882,6 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceThreadPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceThreadParams = {
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
after?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
before?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
spanId?: string;
|
||||
};
|
||||
|
||||
export type GetTraceThread200 = {
|
||||
data: SpantypesGettableTraceThreadDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -4,17 +4,11 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -22,9 +16,6 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceThread200,
|
||||
GetTraceThreadParams,
|
||||
GetTraceThreadPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -36,26 +27,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
@@ -156,121 +127,6 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceThread200>({
|
||||
url: `/api/v1/traces/${traceID}/thread`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryKey = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/traces/${traceID}/thread`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
|
||||
signal,
|
||||
}) => getTraceThread({ traceID }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceThreadQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceThread>>
|
||||
>;
|
||||
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceThread<
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceThreadQueryOptions(
|
||||
{ traceID },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const invalidateGetTraceThread = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -5,18 +5,23 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: string;
|
||||
tooltip: ReactNode;
|
||||
tooltipClassName?: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function EntityMetadataItem({
|
||||
tooltip,
|
||||
tooltipClassName,
|
||||
icon,
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title={tooltip}>
|
||||
<TooltipSimple
|
||||
title={tooltip}
|
||||
tooltipContentProps={{ className: tooltipClassName }}
|
||||
>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
@@ -27,6 +32,7 @@ function EntityMetadataItem({
|
||||
|
||||
EntityMetadataItem.defaultProps = {
|
||||
icon: null,
|
||||
tooltipClassName: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataItem;
|
||||
|
||||
@@ -8,3 +8,7 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import {
|
||||
CalendarClock,
|
||||
CircleDollarSign,
|
||||
Coins,
|
||||
Server,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import { formatCost, formatTokens } from '../SpanHoverCard/aiUsage';
|
||||
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface EntityMetadataRowProps {
|
||||
@@ -16,6 +26,8 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: TraceTokenUsage;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -32,6 +44,8 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const durationTooltip =
|
||||
@@ -91,6 +105,25 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
tooltipClassName={tooltipStyles.tooltipContent}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(tokens.input)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{cost != null && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<CircleDollarSign size={ICON_SIZE} />}
|
||||
>
|
||||
{formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +135,8 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { formatTokens } from '../SpanHoverCard/aiUsage';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: TraceTokenUsage;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={`${tooltipStyles.body} ${styles.tokenTooltip}`}>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Tokens" value={formatTokens(input + output)} isTotal />
|
||||
</TooltipSection>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Input" value={formatTokens(input)} />
|
||||
<TooltipRow label="Output" value={formatTokens(output)} />
|
||||
{cacheRead !== undefined && (
|
||||
<TooltipRow label="Cache read" value={formatTokens(cacheRead)} />
|
||||
)}
|
||||
{cacheWrite !== undefined && (
|
||||
<TooltipRow label="Cache write" value={formatTokens(cacheWrite)} />
|
||||
)}
|
||||
{reasoning !== undefined && (
|
||||
<TooltipRow label="Reasoning" value={formatTokens(reasoning)} />
|
||||
)}
|
||||
</TooltipSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/genAi';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
// The outline variant derives text, fill and border from this color.
|
||||
style={{ '--badge-background': color } as CSSProperties}
|
||||
data-testid={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import SpanHintBadge from '../SpanHintBadge';
|
||||
|
||||
const spanWith = (
|
||||
attributes: Record<string, string | number>,
|
||||
responseStatusCode = '',
|
||||
): SpanV3 =>
|
||||
({
|
||||
attributes,
|
||||
resource: {},
|
||||
response_status_code: responseStatusCode,
|
||||
}) as unknown as SpanV3;
|
||||
|
||||
describe('SpanHintBadge', () => {
|
||||
it.each([
|
||||
[{ 'gen_ai.request.model': 'gpt-4o' }, 'LLM'],
|
||||
[{ 'gen_ai.tool.name': 'search' }, 'TOOL'],
|
||||
[{ 'gen_ai.agent.name': 'planner' }, 'AGENT'],
|
||||
])('shows only the AI badge for %p', (attributes, label) => {
|
||||
render(<SpanHintBadge span={spanWith(attributes, '200')} />);
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
expect(screen.queryByText('200')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the HTTP status code for non-AI spans', () => {
|
||||
render(<SpanHintBadge span={spanWith({}, '503')} />);
|
||||
|
||||
expect(screen.getByText('503')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for a non-AI span without a status code', () => {
|
||||
const { container } = render(<SpanHintBadge span={spanWith({})} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
10
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
10
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AiSpanKind } from '../utils/genAi';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
|
||||
};
|
||||
@@ -12,37 +12,16 @@
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -12,6 +14,12 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getSpanAiDetails, SpanAiDetails } from './aiUsage';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -38,6 +46,7 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -47,33 +56,46 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
<div className={cx(tooltipStyles.body, styles.content)}>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={tooltipStyles.title} style={{ color }}>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="medium">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TooltipSection>
|
||||
{ai?.model && <TooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TooltipRow label="agent" value={ai.agentName} />}
|
||||
<TooltipRow label="status" value={hasError ? 'error' : 'ok'} />
|
||||
<TooltipRow label="start" value={`${toFixed(relativeStartMs, 2)} ms`} />
|
||||
<TooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
/>
|
||||
</TooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
<TooltipSection>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
<TooltipRow
|
||||
key={row.key}
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -137,6 +159,7 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getSpanAiDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
@@ -166,7 +189,7 @@ export function SpanHoverCard({
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className={styles.popover}
|
||||
className={cx(tooltipStyles.tooltipContent, styles.popover)}
|
||||
>
|
||||
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
|
||||
</TooltipContent>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getUsageTotals,
|
||||
SpanAiUsage,
|
||||
} from './aiUsage';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const { outputTokens = 0, cacheReadTokens, cacheCreationTokens, cost } = usage;
|
||||
const { inputUsage, totalUsage } = getUsageTotals(usage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Input usage" value={formatTokens(inputUsage)} isTotal />
|
||||
{cacheReadTokens !== undefined && (
|
||||
<TooltipRow label="cache read" value={formatTokens(cacheReadTokens)} />
|
||||
)}
|
||||
{cacheCreationTokens !== undefined && (
|
||||
<TooltipRow
|
||||
label="cache creation"
|
||||
value={formatTokens(cacheCreationTokens)}
|
||||
/>
|
||||
)}
|
||||
</TooltipSection>
|
||||
<TooltipSection>
|
||||
<TooltipRow
|
||||
label="Output usage"
|
||||
value={formatTokens(outputTokens)}
|
||||
isTotal
|
||||
/>
|
||||
</TooltipSection>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Total usage" value={formatTokens(totalUsage)} isTotal />
|
||||
{cost !== undefined && <TooltipRow label="cost" value={formatCost(cost)} />}
|
||||
</TooltipSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getSpanAiDetails, getUsageTotals } from '../aiUsage';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard';
|
||||
|
||||
const baseProps = {
|
||||
spanName: 'chat gpt-4o',
|
||||
color: '#fff',
|
||||
hasError: false,
|
||||
relativeStartMs: 268,
|
||||
durationMs: 1110,
|
||||
};
|
||||
|
||||
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
|
||||
({ attributes, resource: {} }) as unknown as SpanV3;
|
||||
|
||||
const usageAttributes = {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 200,
|
||||
'gen_ai.usage.cache_read.input_tokens': 600,
|
||||
'gen_ai.usage.cache_creation.input_tokens': 100,
|
||||
};
|
||||
|
||||
describe('getSpanAiDetails', () => {
|
||||
it('returns undefined for a span without gen_ai attributes', () => {
|
||||
expect(getSpanAiDetails(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads model, tool, agent, token usage and cost', () => {
|
||||
expect(
|
||||
getSpanAiDetails(
|
||||
spanWith({
|
||||
'gen_ai.request.model': 'gpt-4o',
|
||||
'gen_ai.tool.name': 'search',
|
||||
'gen_ai.agent.name': 'planner',
|
||||
'gen_ai.usage.input_tokens': '120',
|
||||
'gen_ai.usage.output_tokens': 30,
|
||||
'signoz.gen_ai.usage.tokens.cost': '0.00000056',
|
||||
}),
|
||||
),
|
||||
).toStrictEqual({
|
||||
model: 'gpt-4o',
|
||||
toolName: 'search',
|
||||
agentName: 'planner',
|
||||
usage: {
|
||||
inputTokens: 120,
|
||||
outputTokens: 30,
|
||||
cacheReadTokens: undefined,
|
||||
cacheCreationTokens: undefined,
|
||||
cost: 0.00000056,
|
||||
isAnthropic: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each<Record<string, string>>([
|
||||
{ 'gen_ai.provider.name': 'Anthropic' },
|
||||
{ 'gen_ai.system': 'anthropic' },
|
||||
{ 'gen_ai.response.model': 'claude-sonnet-4' },
|
||||
{ 'gen_ai.request.model': 'anthropic.claude-v2' },
|
||||
])('detects Anthropic from %p', (attributes) => {
|
||||
expect(
|
||||
getSpanAiDetails(spanWith({ ...usageAttributes, ...attributes }))?.usage
|
||||
?.isAnthropic,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsageTotals', () => {
|
||||
it('adds cache tokens to input for Anthropic', () => {
|
||||
const usage = getSpanAiDetails(
|
||||
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'anthropic' }),
|
||||
)?.usage;
|
||||
|
||||
expect(usage && getUsageTotals(usage)).toStrictEqual({
|
||||
inputUsage: 1700,
|
||||
totalUsage: 1900,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats input as already including cache tokens for other providers', () => {
|
||||
const usage = getSpanAiDetails(
|
||||
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'openai' }),
|
||||
)?.usage;
|
||||
|
||||
expect(usage && getUsageTotals(usage)).toStrictEqual({
|
||||
inputUsage: 1000,
|
||||
totalUsage: 1200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpanTooltipContent', () => {
|
||||
it('renders status, start and duration for a non-AI span', () => {
|
||||
render(<SpanTooltipContent {...baseProps} />);
|
||||
|
||||
expect(screen.getByText('chat gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||
expect(screen.getByText('268 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.11 s')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Usage Breakdown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the usage breakdown with cache rows and cost', () => {
|
||||
render(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{
|
||||
model: 'gpt-4o',
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 600,
|
||||
cacheCreationTokens: 100,
|
||||
cost: 0.00000056,
|
||||
isAnthropic: false,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Usage Breakdown')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,000')).toBeInTheDocument();
|
||||
expect(screen.getByText('cache read')).toBeInTheDocument();
|
||||
expect(screen.getByText('600')).toBeInTheDocument();
|
||||
expect(screen.getByText('cache creation')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,200')).toBeInTheDocument();
|
||||
expect(screen.getByText('$ 0.00000056')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides cache rows and cost when the attributes are absent', () => {
|
||||
render(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{ usage: { inputTokens: 10, outputTokens: 5, isAnthropic: false } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('cache read')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('cache creation')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('cost')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
97
frontend/src/pages/TraceDetailsV3/SpanHoverCard/aiUsage.ts
Normal file
97
frontend/src/pages/TraceDetailsV3/SpanHoverCard/aiUsage.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { GEN_AI_KEYS } from '../utils/genAi';
|
||||
|
||||
export interface SpanAiUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
cost?: number;
|
||||
isAnthropic: boolean;
|
||||
}
|
||||
|
||||
export interface SpanAiDetails {
|
||||
model?: string;
|
||||
toolName?: string;
|
||||
agentName?: string;
|
||||
usage?: SpanAiUsage;
|
||||
}
|
||||
|
||||
function getNumber(span: SpanV3, key: string): number | undefined {
|
||||
const value = getSpanAttribute(span, key);
|
||||
if (value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function isAnthropicSpan(span: SpanV3): boolean {
|
||||
const provider = (
|
||||
getSpanAttribute(span, GEN_AI_KEYS.providerName) ??
|
||||
getSpanAttribute(span, GEN_AI_KEYS.system) ??
|
||||
''
|
||||
).toLowerCase();
|
||||
const model = (
|
||||
getSpanAttribute(span, GEN_AI_KEYS.responseModel) ??
|
||||
getSpanAttribute(span, GEN_AI_KEYS.requestModel) ??
|
||||
''
|
||||
).toLowerCase();
|
||||
|
||||
return (
|
||||
provider === 'anthropic' ||
|
||||
model.includes('claude') ||
|
||||
model.includes('anthropic')
|
||||
);
|
||||
}
|
||||
|
||||
export function getSpanAiDetails(span: SpanV3): SpanAiDetails | undefined {
|
||||
const usage: SpanAiUsage = {
|
||||
inputTokens: getNumber(span, GEN_AI_KEYS.inputTokens),
|
||||
outputTokens: getNumber(span, GEN_AI_KEYS.outputTokens),
|
||||
cacheReadTokens: getNumber(span, GEN_AI_KEYS.cacheReadTokens),
|
||||
cacheCreationTokens: getNumber(span, GEN_AI_KEYS.cacheCreationTokens),
|
||||
cost: getNumber(span, GEN_AI_KEYS.cost),
|
||||
isAnthropic: isAnthropicSpan(span),
|
||||
};
|
||||
const hasUsage =
|
||||
usage.inputTokens !== undefined || usage.outputTokens !== undefined;
|
||||
|
||||
const details: SpanAiDetails = {
|
||||
model: getSpanAttribute(span, GEN_AI_KEYS.requestModel) || undefined,
|
||||
toolName: getSpanAttribute(span, GEN_AI_KEYS.toolName) || undefined,
|
||||
agentName: getSpanAttribute(span, GEN_AI_KEYS.agentName) || undefined,
|
||||
usage: hasUsage ? usage : undefined,
|
||||
};
|
||||
|
||||
return details.model || details.toolName || details.agentName || hasUsage
|
||||
? details
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic reports input_tokens without cached tokens; every other provider
|
||||
* (OpenAI, Azure OpenAI, Gemini, Mistral, DeepSeek, Groq, xAI) counts cached
|
||||
* tokens inside input_tokens.
|
||||
*/
|
||||
export function getUsageTotals(usage: SpanAiUsage): {
|
||||
inputUsage: number;
|
||||
totalUsage: number;
|
||||
} {
|
||||
const input = usage.inputTokens ?? 0;
|
||||
const inputUsage = usage.isAnthropic
|
||||
? input + (usage.cacheReadTokens ?? 0) + (usage.cacheCreationTokens ?? 0)
|
||||
: input;
|
||||
|
||||
return { inputUsage, totalUsage: inputUsage + (usage.outputTokens ?? 0) };
|
||||
}
|
||||
|
||||
export function formatTokens(value: number): string {
|
||||
return value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function formatCost(value: number): string {
|
||||
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
|
||||
}
|
||||
@@ -34,6 +34,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: unset;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -18,40 +18,26 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
import TraceDetailsTabs from './TraceDetailsTabs';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
interface FilterMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface TraceMetadataForHeader {
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
hasMissingSpans: boolean;
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
filterMetadata: FilterMetadata;
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
isDataLoaded?: boolean;
|
||||
traceMetadata?: TraceMetadataForHeader;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 3;
|
||||
@@ -73,16 +59,22 @@ function DetailsLoader(): JSX.Element {
|
||||
}
|
||||
|
||||
function TraceDetailsHeader({
|
||||
filterMetadata,
|
||||
onFilteredSpansChange,
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV3URLProps>();
|
||||
const { data: summary, isLoading } = useTraceSummary();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
|
||||
// Overview-only panels must not linger over another tab.
|
||||
useEffect(() => {
|
||||
setIsPreviewFieldsOpen(false);
|
||||
setIsAnalyticsOpen(false);
|
||||
}, [tab]);
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
@@ -116,9 +108,13 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const durationMs = traceMetadata
|
||||
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
|
||||
: 0;
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: summary?.totalSpansCount ?? 0,
|
||||
totalErrorSpansCount: summary?.totalErrorSpansCount ?? 0,
|
||||
});
|
||||
|
||||
const startTime = (summary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (summary?.endTimestampMillis ?? 0) / 1e3;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
@@ -140,9 +136,10 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
<TraceDetailsTabs />
|
||||
</div>
|
||||
)}
|
||||
{isDataLoaded && (
|
||||
{isOverview && onFilteredSpansChange && summary && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -171,9 +168,9 @@ function TraceDetailsHeader({
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
traceId={traceID || ''}
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
totalSpansCount={traceMetadata?.totalSpansCount || 0}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
totalSpansCount={summary.totalSpansCount}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -183,9 +180,9 @@ function TraceDetailsHeader({
|
||||
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
|
||||
>
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
traceID={traceID || ''}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
isExpanded={isFilterExpanded}
|
||||
onExpand={(): void => setIsFilterExpanded(true)}
|
||||
@@ -196,28 +193,29 @@ function TraceDetailsHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showTraceDetails && (
|
||||
{(showTraceDetails || !isOverview) && (
|
||||
<div className={styles.subHeader}>
|
||||
{traceMetadata ? (
|
||||
{isLoading || !summary ? (
|
||||
<DetailsLoader />
|
||||
) : (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: traceMetadata.rootServiceName,
|
||||
entryPoint: traceMetadata.rootServiceEntryPoint,
|
||||
name: summary.rootServiceName,
|
||||
entryPoint: summary.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={durationMs}
|
||||
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
|
||||
durationMs={summary.endTimestampMillis - summary.startTimestampMillis}
|
||||
timestamp={dayjs(summary.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceMetadata.rootSpanStatusCode}
|
||||
tokens={summary.ai?.tokens}
|
||||
cost={summary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{isOverview && summary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
// Clicking the active item deselects it and reports ''.
|
||||
if (value) {
|
||||
void setTab(value as TraceDetailsTab);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={tab}
|
||||
onChange={handleChange}
|
||||
className={styles.tabs}
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Overview}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-overview"
|
||||
>
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Thread}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-thread"
|
||||
>
|
||||
Thread
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsTabs;
|
||||
@@ -1,9 +1,12 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { MOCK_TRACE_SUMMARY } from '../mockTraceSummary';
|
||||
import * as traceSummaryModule from '../useTraceSummary';
|
||||
import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
|
||||
const mockGoBack = jest.fn();
|
||||
@@ -50,15 +53,9 @@ jest.mock('components/FieldsSelector', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
filterMetadata: {
|
||||
startTime: 0,
|
||||
endTime: 1,
|
||||
traceId: 'trace-123',
|
||||
},
|
||||
onFilteredSpansChange: jest.fn(),
|
||||
isDataLoaded: false,
|
||||
};
|
||||
const baseProps = { onFilteredSpansChange: jest.fn() };
|
||||
|
||||
const metadataText = /Missing Span/;
|
||||
|
||||
describe('TraceDetailsHeader – back button', () => {
|
||||
beforeEach(() => {
|
||||
@@ -94,8 +91,8 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
mockReplace.mockClear();
|
||||
});
|
||||
|
||||
it('does not render the action buttons while data is still loading', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
|
||||
it('does not render the action buttons without a filter handler', () => {
|
||||
render(<TraceDetailsHeader />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
@@ -105,8 +102,8 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Analytics and Settings action buttons once data is loaded', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
it('renders Analytics and Settings action buttons', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
@@ -117,7 +114,7 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
const panel = screen.getByTestId('analytics-panel');
|
||||
expect(panel).toHaveAttribute('data-open', 'false');
|
||||
@@ -133,68 +130,125 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
// Plain prop, no API mock needed: traceMetadata is passed straight in.
|
||||
const traceMetadata = {
|
||||
startTimestampMillis: 1_700_000_000_000,
|
||||
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
|
||||
rootServiceName: 'inventory-frontend',
|
||||
rootServiceEntryPoint: 'large-trace-root',
|
||||
rootSpanStatusCode: '404',
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
};
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders the metadata (service, entry point, duration, status) when provided', () => {
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
it('shows skeletons instead of the metadata while the summary loads', () => {
|
||||
jest
|
||||
.spyOn(traceSummaryModule, 'useTraceSummary')
|
||||
.mockReturnValue({ data: undefined, isLoading: true });
|
||||
const { container } = render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
// Duration goes through the shared formatter (e.g. "2 min").
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders the summary metadata with tokens and cost', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
const { startTimestampMillis, endTimestampMillis } = MOCK_TRACE_SUMMARY;
|
||||
const duration = getYAxisFormattedValue(
|
||||
`${traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis}`,
|
||||
`${endTimestampMillis - startTimestampMillis}`,
|
||||
'ms',
|
||||
);
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
expect(screen.getByText(duration)).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.getByText('$ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
// Visible by default (showTraceDetails defaults to true).
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
|
||||
// Hide it.
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /hide trace details/i }),
|
||||
);
|
||||
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
|
||||
// Show it again.
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /show trace details/i }),
|
||||
);
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the metadata row when traceMetadata is absent', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
|
||||
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – tabs', () => {
|
||||
// In-memory URL state, so a tab switch can't leak into the next test.
|
||||
const renderHeader = (): void => {
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<TraceDetailsHeader {...baseProps} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
};
|
||||
|
||||
it('shows the Overview-only sections on the Overview tab', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /trace options/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps only the metadata row on the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /trace options/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /hide trace details/i }),
|
||||
);
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the Analytics panel when switching to the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^analytics$/i }));
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { TraceSummary } from './types';
|
||||
|
||||
// Placeholder until GET /api/v1/traces/{id}/summary feeds the header.
|
||||
export const MOCK_TRACE_SUMMARY: TraceSummary = {
|
||||
startTimestampMillis: 1789627365929,
|
||||
endTimestampMillis: 1789627367867,
|
||||
rootServiceName: '',
|
||||
rootServiceEntryPoint: 'Missing Span',
|
||||
totalSpansCount: 3,
|
||||
totalErrorSpansCount: 0,
|
||||
hasMissingSpans: true,
|
||||
ai: {
|
||||
tokens: {
|
||||
input: 12040,
|
||||
output: 3110,
|
||||
cacheRead: 8000,
|
||||
cacheWrite: 1200,
|
||||
reasoning: 900,
|
||||
},
|
||||
totalCost: 0.0421,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface TraceTokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
/** Response of GET /api/v1/traces/{id}/summary. */
|
||||
export interface TraceSummary {
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
totalSpansCount: number;
|
||||
totalErrorSpansCount: number;
|
||||
hasMissingSpans: boolean;
|
||||
ai?: {
|
||||
tokens?: TraceTokenUsage;
|
||||
// null when no span has a cost attribute
|
||||
totalCost: number | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MOCK_TRACE_SUMMARY } from './mockTraceSummary';
|
||||
import { TraceSummary } from './types';
|
||||
|
||||
interface UseTraceSummaryResult {
|
||||
data: TraceSummary | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// Placeholder until GET /api/v1/traces/{id}/summary is available.
|
||||
export function useTraceSummary(): UseTraceSummaryResult {
|
||||
return { data: MOCK_TRACE_SUMMARY, isLoading: false };
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layoutRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -0,0 +1,500 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import NoData from '../NoData/NoData';
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
useTraceStore,
|
||||
} from '../stores/traceStore';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from '../TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from '../utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsOverview.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
interface TraceDetailsOverviewProps {
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}
|
||||
|
||||
function TraceDetailsOverview({
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: TraceDetailsOverviewProps): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
}, [urlQuery, safeNavigate]);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
newVariant,
|
||||
);
|
||||
setSpanDetailVariant(newVariant);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsOverview;
|
||||
@@ -0,0 +1,5 @@
|
||||
function TraceDetailsThread(): JSX.Element {
|
||||
return <div data-testid="trace-details-thread" />;
|
||||
}
|
||||
|
||||
export default TraceDetailsThread;
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import TraceStoreSync from '../stores/TraceStoreSync';
|
||||
|
||||
import styles from './TraceDetailsWrapper.module.scss';
|
||||
|
||||
interface TraceDetailsWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TraceDetailsWrapper({
|
||||
children,
|
||||
}: TraceDetailsWrapperProps): JSX.Element {
|
||||
return (
|
||||
<TraceStoreSync>
|
||||
<div className={styles.root}>{children}</div>
|
||||
</TraceStoreSync>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsWrapper;
|
||||
@@ -33,10 +33,5 @@
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import cx from 'classnames';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
@@ -17,6 +18,7 @@ import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
|
||||
import { useScrollToSpan } from './hooks/useScrollToSpan';
|
||||
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './FlamegraphCanvas.module.scss';
|
||||
|
||||
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
@@ -203,7 +205,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
className={cx(tooltipStyles.surface, styles.tooltip)}
|
||||
style={{
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TooltipRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
isTotal?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function TooltipRow({
|
||||
label,
|
||||
value,
|
||||
isTotal,
|
||||
testId,
|
||||
}: TooltipRowProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.row} data-testid={testId}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={isTotal ? undefined : 'muted'}
|
||||
truncate={1}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" truncate={1} className={styles.rowValue}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TooltipRow;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TooltipSectionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TooltipSection({ children }: TooltipSectionProps): JSX.Element {
|
||||
return <div className={styles.section}>{children}</div>;
|
||||
}
|
||||
|
||||
export default TooltipSection;
|
||||
@@ -0,0 +1,82 @@
|
||||
// Figma `dark/shadow-dropdown` popover surface, shared by every trace-details
|
||||
// tooltip so they look the same.
|
||||
$surface-background: color-mix(in srgb, var(--l3-background) 60%, transparent);
|
||||
$surface-border-color: var(--l2-border);
|
||||
$surface-radius: var(--radius-2);
|
||||
$surface-padding: var(--spacing-4);
|
||||
$surface-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
// For plain elements, e.g. the flamegraph's cursor-following portal.
|
||||
.surface {
|
||||
background-color: $surface-background;
|
||||
border: 1px solid $surface-border-color;
|
||||
border-radius: $surface-radius;
|
||||
padding: $surface-padding;
|
||||
box-shadow: $surface-shadow;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
// For @signozhq/ui Tooltip content: it paints both the bubble and the arrow
|
||||
// from these variables.
|
||||
.tooltipContent {
|
||||
--tooltip-background: #{$surface-background};
|
||||
--tooltip-border-color: #{$surface-border-color};
|
||||
--tooltip-border-radius: #{$surface-radius};
|
||||
--tooltip-padding: #{$surface-padding};
|
||||
--tooltip-box-shadow: #{$surface-shadow};
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
letter-spacing: var(--paragraph-small-400-letter-spacing);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
.title {
|
||||
--typography-font-family: var(--code-small-500-font-family);
|
||||
--typography-font-size: var(--code-small-500-font-size);
|
||||
--typography-font-weight: var(--code-small-500-font-weight);
|
||||
--typography-line-height: var(--code-small-500-line-height);
|
||||
letter-spacing: var(--code-small-500-letter-spacing);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid;
|
||||
border-image: linear-gradient(
|
||||
90deg,
|
||||
var(--l3-background-transparent),
|
||||
var(--l3-border),
|
||||
var(--l3-background-transparent)
|
||||
)
|
||||
1;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex-shrink: 0;
|
||||
max-width: 60%;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -217,8 +217,9 @@
|
||||
.statusCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
padding-left: var(--spacing-2);
|
||||
|
||||
--badge-border-radius: 2px;
|
||||
--badge-padding: 3px 6px;
|
||||
@@ -408,6 +409,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aiSpanIcon {
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.treeLabel {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
import cx from 'classnames';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
@@ -33,7 +32,13 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link,
|
||||
ListPlus,
|
||||
Sparkle,
|
||||
} from '@signozhq/icons';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
@@ -44,6 +49,8 @@ import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
|
||||
import { isAiSpan } from '../../../utils/genAi';
|
||||
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import { IInterestedSpan } from '../../types';
|
||||
|
||||
@@ -328,14 +335,23 @@ const SpanOverview = memo(function SpanOverview({
|
||||
</span>
|
||||
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
{isAiSpan(span) ? (
|
||||
<Sparkle
|
||||
size={10}
|
||||
color={effectiveColor}
|
||||
fill={effectiveColor}
|
||||
className={styles.aiSpanIcon}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Span name + service name */}
|
||||
<span className={styles.treeLabel}>
|
||||
@@ -971,9 +987,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{span.response_status_code && (
|
||||
<HttpStatusBadge statusCode={span.response_status_code} />
|
||||
)}
|
||||
<SpanHintBadge span={span} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -53,3 +53,8 @@ export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
|
||||
);
|
||||
|
||||
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
|
||||
|
||||
export enum TraceDetailsTab {
|
||||
Overview = 'overview',
|
||||
Thread = 'thread',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
|
||||
export function useTraceDetailsTab(): UseQueryStateReturn<
|
||||
TraceDetailsTab,
|
||||
TraceDetailsTab
|
||||
> {
|
||||
return useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum(Object.values(TraceDetailsTab)).withDefault(
|
||||
TraceDetailsTab.Overview,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,89 +1,18 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import NoData from './NoData/NoData';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import { TraceDetailsTab } from './constants';
|
||||
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
|
||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from './TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from './TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from './utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsV3.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview';
|
||||
import TraceDetailsThread from './TraceDetailsThread/TraceDetailsThread';
|
||||
import TraceDetailsWrapper from './TraceDetailsWrapper/TraceDetailsWrapper';
|
||||
|
||||
function TraceDetailsV3(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [isFilterActive, setIsFilterActive] = useState(false);
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
}, [urlQuery, safeNavigate]);
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean): void => {
|
||||
setFilteredSpanIds(spanIds);
|
||||
@@ -92,453 +21,26 @@ function TraceDetailsV3(): JSX.Element {
|
||||
[],
|
||||
);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
// The filter query lives in Filters' local state, which unmounts with the
|
||||
// Overview tab; drop its result too so both start empty on return.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
if (!isOverview) {
|
||||
handleFilteredSpansChange([], false);
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
||||
|
||||
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
newVariant,
|
||||
);
|
||||
setSpanDetailVariant(newVariant);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const filterMetadata = useMemo(
|
||||
() => ({
|
||||
startTime: (traceData?.payload?.startTimestampMillis || 0) / 1e3,
|
||||
endTime: (traceData?.payload?.endTimestampMillis || 0) / 1e3,
|
||||
traceId: traceId || '',
|
||||
}),
|
||||
[
|
||||
traceData?.payload?.startTimestampMillis,
|
||||
traceData?.payload?.endTimestampMillis,
|
||||
traceId,
|
||||
],
|
||||
);
|
||||
|
||||
const traceMetadataForHeader = useMemo(():
|
||||
| TraceMetadataForHeader
|
||||
| undefined => {
|
||||
const payload = traceData?.payload;
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const rootSpan = payload.spans?.find((s) => s.level === 0);
|
||||
return {
|
||||
startTimestampMillis: payload.startTimestampMillis,
|
||||
endTimestampMillis: payload.endTimestampMillis,
|
||||
rootServiceName: payload.rootServiceName,
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
hasMissingSpans: payload.hasMissingSpans || false,
|
||||
totalSpansCount: payload.totalSpansCount || 0,
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
}, [isOverview, handleFilteredSpansChange]);
|
||||
|
||||
return (
|
||||
<TraceStoreSync availableColorByFields={availableColorByFields}>
|
||||
<div className={styles.root}>
|
||||
<TraceDetailsHeader
|
||||
filterMetadata={filterMetadata}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
|
||||
traceMetadata={traceMetadataForHeader}
|
||||
<TraceDetailsWrapper>
|
||||
<TraceDetailsHeader onFilteredSpansChange={handleFilteredSpansChange} />
|
||||
{isOverview ? (
|
||||
<TraceDetailsOverview
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall')
|
||||
? waterfallChildren
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TraceStoreSync>
|
||||
) : (
|
||||
<TraceDetailsThread />
|
||||
)}
|
||||
</TraceDetailsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,36 +4,28 @@ import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
setTraceStoreCallbacks,
|
||||
setTraceStoreUserPreferences,
|
||||
} from './traceStore';
|
||||
|
||||
interface TraceStoreSyncProps {
|
||||
availableColorByFields: string[] | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
|
||||
* from AppContext, and the user-pref mutation hook) into the Zustand store.
|
||||
* Bridges React-managed inputs (`userPreferences` from AppContext and the
|
||||
* user-pref mutation hook) into the Zustand store. `availableColorByFields`
|
||||
* is pushed by TraceDetailsOverview, which owns the span data.
|
||||
*
|
||||
* Renders nothing until `userPreferences` resolves so the flamegraph never
|
||||
* paints with the default colour first and then swaps to the user's
|
||||
* persisted choice. AppProvider fires the prefs query as soon as the user
|
||||
* is logged in, so this gate is usually already settled by mount time.
|
||||
*/
|
||||
function TraceStoreSync({
|
||||
availableColorByFields,
|
||||
children,
|
||||
}: TraceStoreSyncProps): JSX.Element | null {
|
||||
function TraceStoreSync({ children }: TraceStoreSyncProps): JSX.Element | null {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreUserPreferences(userPreferences ?? null);
|
||||
}, [userPreferences]);
|
||||
|
||||
@@ -37,7 +37,7 @@ interface TraceStoreState {
|
||||
availableColorByOptions: ColorByOption[];
|
||||
previewFields: TelemetryFieldKey[];
|
||||
|
||||
// --- Setters used only by TraceStoreSync ---
|
||||
// --- Setters used only by TraceStoreSync and TraceDetailsOverview ---
|
||||
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
|
||||
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
|
||||
setCallbacks: (callbacks: {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { AiSpanKind, getAiSpanKind, isAiSpan } from '../genAi';
|
||||
|
||||
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
|
||||
({ attributes, resource: {} }) as unknown as SpanV3;
|
||||
|
||||
describe('getAiSpanKind', () => {
|
||||
it.each<[Record<string, string | number>, AiSpanKind]>([
|
||||
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
|
||||
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
|
||||
[
|
||||
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
|
||||
AiSpanKind.Tool,
|
||||
],
|
||||
[
|
||||
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
|
||||
AiSpanKind.Agent,
|
||||
],
|
||||
])('classifies %p as %s', (attributes, kind) => {
|
||||
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
|
||||
});
|
||||
|
||||
it('returns undefined for non-AI spans and empty values', () => {
|
||||
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
|
||||
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
|
||||
expect(isAiSpan(spanWith({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
54
frontend/src/pages/TraceDetailsV3/utils/genAi.ts
Normal file
54
frontend/src/pages/TraceDetailsV3/utils/genAi.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export const GEN_AI_KEYS = {
|
||||
providerName: 'gen_ai.provider.name',
|
||||
system: 'gen_ai.system',
|
||||
requestModel: 'gen_ai.request.model',
|
||||
responseModel: 'gen_ai.response.model',
|
||||
toolName: 'gen_ai.tool.name',
|
||||
agentName: 'gen_ai.agent.name',
|
||||
inputTokens: 'gen_ai.usage.input_tokens',
|
||||
outputTokens: 'gen_ai.usage.output_tokens',
|
||||
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
|
||||
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
|
||||
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
|
||||
cost: 'signoz.gen_ai.usage.tokens.cost',
|
||||
} as const;
|
||||
|
||||
export enum AiSpanKind {
|
||||
Llm = 'llm',
|
||||
Tool = 'tool',
|
||||
Agent = 'agent',
|
||||
}
|
||||
|
||||
const LLM_KEYS = [
|
||||
GEN_AI_KEYS.requestModel,
|
||||
GEN_AI_KEYS.inputTokens,
|
||||
GEN_AI_KEYS.outputTokens,
|
||||
GEN_AI_KEYS.cacheReadTokens,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
GEN_AI_KEYS.reasoningTokens,
|
||||
];
|
||||
|
||||
function hasAttribute(span: SpanV3, key: string): boolean {
|
||||
const value = span.attributes?.[key] ?? span.resource?.[key];
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}
|
||||
|
||||
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
|
||||
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
|
||||
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
|
||||
return AiSpanKind.Tool;
|
||||
}
|
||||
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
|
||||
return AiSpanKind.Agent;
|
||||
}
|
||||
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
|
||||
return AiSpanKind.Llm;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isAiSpan(span: SpanV3): boolean {
|
||||
return getAiSpanKind(span) !== undefined;
|
||||
}
|
||||
@@ -67,23 +67,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/traces/{traceID}/thread", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetThread),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetTraceThread",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get thread view for a trace",
|
||||
Description: "Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
|
||||
RequestQuery: new(spantypes.QueryableThread),
|
||||
Response: new(spantypes.GettableTraceThread),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -75,25 +75,3 @@ func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetThread(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.QueryableThread)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
query, err := spantypes.NewThreadQuery(req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetThread(r.Context(), mux.Vars(r)["traceID"], query)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -173,49 +173,6 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
|
||||
), nil
|
||||
}
|
||||
|
||||
func (m *module) GetThread(ctx context.Context, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// One extra row per side signals a next/prev page; NewGettableTraceThread trims the response back to query.Limit.
|
||||
page := spantypes.ThreadPage{Limit: query.Limit + 1}
|
||||
switch {
|
||||
case query.SpanID != "":
|
||||
// Window centred on the span: fetch both directions, NewGettableTraceThread splits the limit.
|
||||
anchor, err := m.store.GetThreadCursor(ctx, traceID, summary, query.SpanID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := m.store.GetThreadSpans(ctx, traceID, summary, spantypes.ThreadPage{Cursor: anchor, From: spantypes.ThreadBefore, Limit: page.Limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ThreadAt keeps the anchor when it carries messages; otherwise the filter drops it.
|
||||
after, err := m.store.GetThreadSpans(ctx, traceID, summary, spantypes.ThreadPage{Cursor: anchor, From: spantypes.ThreadAt, Limit: page.Limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, query, before, after), nil
|
||||
case query.Before != nil:
|
||||
page.Cursor, page.From = query.Before, spantypes.ThreadBefore
|
||||
before, err := m.store.GetThreadSpans(ctx, traceID, summary, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, query, before, nil), nil
|
||||
default:
|
||||
// nil for the first page.
|
||||
page.Cursor = query.After
|
||||
after, err := m.store.GetThreadSpans(ctx, traceID, summary, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, query, nil, after), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,23 +11,12 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
|
||||
|
||||
var fullSpanColumns = []string{
|
||||
"duration_nano", "span_id", "has_error", "kind",
|
||||
colServiceName, "name",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
}
|
||||
|
||||
func buildFieldExpr(fieldKey telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
switch fieldKey.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
@@ -134,8 +123,16 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
return []spantypes.StorableSpan{}, nil
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("DISTINCT ON (span_id) timestamp")
|
||||
sb.SelectMore(fullSpanColumns...)
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp",
|
||||
"duration_nano", "span_id", "has_error", "kind",
|
||||
colServiceName, "name",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
for i, id := range spanIDs {
|
||||
@@ -158,81 +155,6 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetThreadSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary, page spantypes.ThreadPage) ([]spantypes.StorableSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("DISTINCT ON (span_id) timestamp")
|
||||
sb.SelectMore(fullSpanColumns...)
|
||||
sb.SelectMore("attributes")
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", summary.End.Unix()),
|
||||
// Reads only the JSON column; spans with messages only in the legacy maps are skipped.
|
||||
// todo(nitya): pick the column from the attribute evolution metadata.
|
||||
sb.Or(
|
||||
sqlbuilder.Escape(fmt.Sprintf("attributes.%s IS NOT NULL", clickhousesql.Identifier(aiobservabilitytypes.GenAIInputMessages))),
|
||||
sqlbuilder.Escape(fmt.Sprintf("attributes.%s IS NOT NULL", clickhousesql.Identifier(aiobservabilitytypes.GenAIOutputMessages))),
|
||||
),
|
||||
)
|
||||
if cursor := page.Cursor; cursor != nil {
|
||||
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
|
||||
// ts_bucket_start bounds are what skip the data on the far side of the cursor.
|
||||
key := "(toUnixTimestamp64Nano(timestamp), span_id)"
|
||||
bucket := int64(cursor.TimeUnixNano / uint64(time.Second))
|
||||
timestamp := fmt.Sprintf("fromUnixTimestamp64Nano(toInt64(%s))", sb.Var(cursor.TimeUnixNano))
|
||||
tuple := sqlbuilder.Tuple(cursor.TimeUnixNano, cursor.SpanID)
|
||||
switch page.From {
|
||||
case spantypes.ThreadBefore:
|
||||
sb.Where(sb.LE("ts_bucket_start", bucket), "timestamp <= "+timestamp, sb.LT(key, tuple))
|
||||
case spantypes.ThreadAt:
|
||||
sb.Where(sb.GE("ts_bucket_start", bucket-1800), "timestamp >= "+timestamp, sb.GE(key, tuple))
|
||||
default:
|
||||
sb.Where(sb.GE("ts_bucket_start", bucket-1800), "timestamp >= "+timestamp, sb.GT(key, tuple))
|
||||
}
|
||||
}
|
||||
// span_id breaks timestamp ties so the order matches the cursor key; otherwise tied spans
|
||||
// can be skipped or repeated across pages.
|
||||
if page.From == spantypes.ThreadBefore {
|
||||
sb.OrderByDesc("timestamp")
|
||||
sb.OrderByDesc("span_id")
|
||||
} else {
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("span_id")
|
||||
}
|
||||
sb.Limit(page.Limit)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.StorableSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread spans")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetThreadCursor(ctx context.Context, traceID string, summary *spantypes.TraceSummary, spanID string) (*spantypes.ThreadCursor, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("toUnixTimestamp64Nano(timestamp)")
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", summary.End.Unix()),
|
||||
sb.E("span_id", spanID),
|
||||
)
|
||||
sb.Limit(1)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var timeUnixNano int64
|
||||
if err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&timeUnixNano); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.NewNotFoundf(spantypes.ErrCodeThreadSpanNotFound, "span %s not found in trace %s", spanID, traceID)
|
||||
}
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread span")
|
||||
}
|
||||
return &spantypes.ThreadCursor{TimeUnixNano: uint64(timeUnixNano), SpanID: spanID}, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
|
||||
@@ -13,7 +13,6 @@ type Handler interface {
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
GetTraceAggregations(http.ResponseWriter, *http.Request)
|
||||
GetFlamegraph(http.ResponseWriter, *http.Request)
|
||||
GetThread(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
@@ -21,5 +20,4 @@ type Module interface {
|
||||
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)
|
||||
GetThread(ctx context.Context, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error)
|
||||
}
|
||||
|
||||
@@ -566,6 +566,24 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// flattenJSONPaths flattens a decoded JSON document into dotted keys, overwriting existing keys in out.
|
||||
func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
|
||||
for k, v := range m {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch child := v.(type) {
|
||||
case map[string]any:
|
||||
flattenJSONPaths(key, child, out)
|
||||
case telemetrystoretypes.JSONValue:
|
||||
flattenJSONPaths(key, child, out)
|
||||
default:
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
|
||||
// unified "attributes" and "resource" keys, and parses the stringified `events`
|
||||
// and `links` columns into structured slices. Raw DB columns are removed.
|
||||
@@ -580,7 +598,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
|
||||
resStr, hasRes := data["resources_string"]
|
||||
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
|
||||
attributes := make(map[string]any)
|
||||
attrJSON.FlattenInto("", attributes)
|
||||
flattenJSONPaths("", attrJSON, attributes)
|
||||
if m, ok := attrStr.(map[string]string); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
|
||||
@@ -36,8 +36,6 @@ type TraceStore interface {
|
||||
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)
|
||||
GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
GetThreadSpans(ctx context.Context, traceID string, summary *TraceSummary, page ThreadPage) ([]StorableSpan, error)
|
||||
GetThreadCursor(ctx context.Context, traceID string, summary *TraceSummary, spanID string) (*ThreadCursor, error)
|
||||
|
||||
GetSpanCountByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
GetSpanDurationByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
threadDefaultLimit = 100
|
||||
threadMaxLimit = 1000
|
||||
)
|
||||
|
||||
const (
|
||||
ThreadAfter ThreadFrom = iota // > cursor, ascending
|
||||
ThreadAt // >= cursor, ascending
|
||||
ThreadBefore // < cursor, descending
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeThreadInvalidLimit = errors.MustNewCode("trace_thread_invalid_limit")
|
||||
ErrCodeThreadInvalidCursor = errors.MustNewCode("trace_thread_invalid_cursor")
|
||||
ErrCodeThreadInvalidAnchor = errors.MustNewCode("trace_thread_invalid_anchor")
|
||||
ErrCodeThreadSpanNotFound = errors.MustNewCode("trace_thread_span_not_found")
|
||||
)
|
||||
|
||||
type QueryableThread struct {
|
||||
// Limit is the page size; 0 means 100.
|
||||
Limit int `query:"limit"`
|
||||
// After is the nextCursor of a page; returns the spans after it.
|
||||
After string `query:"after"`
|
||||
// Before is the prevCursor of a page; returns the spans before it.
|
||||
Before string `query:"before"`
|
||||
// SpanID returns the page around this span. After, Before and SpanID are exclusive.
|
||||
SpanID string `query:"spanId"`
|
||||
}
|
||||
|
||||
type ThreadQuery struct {
|
||||
Limit int
|
||||
After *ThreadCursor
|
||||
Before *ThreadCursor
|
||||
SpanID string
|
||||
}
|
||||
|
||||
// ThreadCursor is the (TimeUnixNano, SpanID) key of a span.
|
||||
type ThreadCursor struct {
|
||||
TimeUnixNano uint64 `json:"t"`
|
||||
SpanID string `json:"s"`
|
||||
}
|
||||
|
||||
type ThreadFrom int
|
||||
|
||||
type ThreadPage struct {
|
||||
Cursor *ThreadCursor
|
||||
From ThreadFrom
|
||||
Limit int
|
||||
}
|
||||
|
||||
type GettableTraceThread struct {
|
||||
Spans []*ThreadSpan `json:"spans" required:"true" nullable:"false"`
|
||||
PrevCursor string `json:"prevCursor,omitempty"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
type ThreadSpan struct {
|
||||
WaterfallSpan
|
||||
timeUnixNano uint64
|
||||
}
|
||||
|
||||
func NewThreadQuery(queryable *QueryableThread) (*ThreadQuery, error) {
|
||||
query := &ThreadQuery{Limit: queryable.Limit, SpanID: queryable.SpanID}
|
||||
if query.Limit < 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidLimit, "limit cannot be negative, got %d", query.Limit)
|
||||
}
|
||||
if query.Limit == 0 {
|
||||
query.Limit = threadDefaultLimit
|
||||
}
|
||||
if query.Limit > threadMaxLimit {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidLimit, "limit cannot exceed %d, got %d", threadMaxLimit, query.Limit)
|
||||
}
|
||||
|
||||
anchors := 0
|
||||
for _, value := range []string{queryable.After, queryable.Before, queryable.SpanID} {
|
||||
if value != "" {
|
||||
anchors++
|
||||
}
|
||||
}
|
||||
if anchors > 1 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidAnchor, "only one of after, before and spanId can be set")
|
||||
}
|
||||
|
||||
var err error
|
||||
if queryable.After != "" {
|
||||
if query.After, err = DecodeThreadCursor(queryable.After); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if queryable.Before != "" {
|
||||
if query.Before, err = DecodeThreadCursor(queryable.Before); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func DecodeThreadCursor(cursor string) (*ThreadCursor, error) {
|
||||
data, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeThreadInvalidCursor, "invalid cursor")
|
||||
}
|
||||
c := new(ThreadCursor)
|
||||
if err := json.Unmarshal(data, c); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeThreadInvalidCursor, "invalid cursor")
|
||||
}
|
||||
if c.SpanID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidCursor, "invalid cursor: missing span id")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c ThreadCursor) Encode() string {
|
||||
data, _ := json.Marshal(c)
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// NewGettableTraceThread takes up to limit+1 spans on each side of the anchor: before in
|
||||
// descending order, after in ascending order. Half the page goes to before, the rest to after,
|
||||
// and a short side gives its room to the other. An extra span on a side sets that side's cursor.
|
||||
func NewGettableTraceThread(traceID string, query *ThreadQuery, before, after []StorableSpan) *GettableTraceThread {
|
||||
nAfter := min(len(after), query.Limit-min(len(before), query.Limit/2))
|
||||
nBefore := min(len(before), query.Limit-nAfter)
|
||||
hasPrev := len(before) > nBefore || query.After != nil
|
||||
hasNext := len(after) > nAfter || query.Before != nil
|
||||
|
||||
spans := make([]*ThreadSpan, 0, nBefore+nAfter)
|
||||
for i := nBefore - 1; i >= 0; i-- {
|
||||
spans = append(spans, newThreadSpan(traceID, &before[i]))
|
||||
}
|
||||
for i := range nAfter {
|
||||
spans = append(spans, newThreadSpan(traceID, &after[i]))
|
||||
}
|
||||
|
||||
thread := &GettableTraceThread{Spans: spans}
|
||||
if len(spans) == 0 {
|
||||
return thread
|
||||
}
|
||||
if hasPrev {
|
||||
thread.PrevCursor = spans[0].cursor().Encode()
|
||||
}
|
||||
if hasNext {
|
||||
thread.NextCursor = spans[len(spans)-1].cursor().Encode()
|
||||
}
|
||||
return thread
|
||||
}
|
||||
|
||||
func (s *ThreadSpan) cursor() ThreadCursor {
|
||||
return ThreadCursor{TimeUnixNano: s.timeUnixNano, SpanID: s.SpanID}
|
||||
}
|
||||
|
||||
func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
|
||||
span := &ThreadSpan{WaterfallSpan: *storable.ToWaterfallSpan(traceID), timeUnixNano: uint64(storable.StartTime.UnixNano())}
|
||||
// client expects millis, as in the waterfall
|
||||
span.TimeUnix = span.TimeUnix / 1_000_000
|
||||
return span
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeThreadCursor(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
cursor string
|
||||
want *ThreadCursor
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "EncodedCursor_RoundTrips", cursor: ThreadCursor{TimeUnixNano: 1757500000123456789, SpanID: "f1fa1bc863e94dd0"}.Encode(), want: &ThreadCursor{TimeUnixNano: 1757500000123456789, SpanID: "f1fa1bc863e94dd0"}},
|
||||
{name: "NotBase64_Rejected", cursor: "not base64!", wantErr: true},
|
||||
{name: "NotJSON_Rejected", cursor: "bm90IGpzb24", wantErr: true},
|
||||
{name: "MissingSpanID_Rejected", cursor: "eyJ0IjogMX0", wantErr: true},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got, err := DecodeThreadCursor(testCase.cursor)
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGettableTraceThread(t *testing.T) {
|
||||
// Span "a" starts at second 1, "b" at 2, and so on.
|
||||
startOf := func(id string) time.Time { return time.Unix(int64(id[0]-'a'+1), 0) }
|
||||
spans := func(ids ...string) []StorableSpan {
|
||||
out := make([]StorableSpan, len(ids))
|
||||
for i, id := range ids {
|
||||
out[i] = StorableSpan{SpanID: id, StartTime: startOf(id)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
cursorAt := func(id string) *ThreadCursor {
|
||||
return &ThreadCursor{TimeUnixNano: uint64(startOf(id).UnixNano()), SpanID: id}
|
||||
}
|
||||
cursorOf := func(id string) string { return cursorAt(id).Encode() }
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
query ThreadQuery
|
||||
// Store rows: up to limit+1 per side, beforeRows in descending order.
|
||||
beforeRows []StorableSpan
|
||||
afterRows []StorableSpan
|
||||
wantResponseSpanIDs []string
|
||||
wantPrevCursor string
|
||||
wantNextCursor string
|
||||
}{
|
||||
{
|
||||
name: "FirstPage_ExtraRow_SetsNextOnly",
|
||||
query: ThreadQuery{Limit: 2},
|
||||
afterRows: spans("a", "b", "c"),
|
||||
wantResponseSpanIDs: []string{"a", "b"},
|
||||
wantNextCursor: cursorOf("b"),
|
||||
},
|
||||
{
|
||||
name: "FirstPage_NoExtraRow_NoCursors",
|
||||
query: ThreadQuery{Limit: 3},
|
||||
afterRows: spans("a", "b"),
|
||||
wantResponseSpanIDs: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "FirstPage_Empty_NoCursors",
|
||||
query: ThreadQuery{Limit: 3},
|
||||
wantResponseSpanIDs: []string{},
|
||||
},
|
||||
{
|
||||
name: "After_NoExtraRow_SetsPrevOnly",
|
||||
query: ThreadQuery{Limit: 2, After: cursorAt("b")},
|
||||
afterRows: spans("c"),
|
||||
wantResponseSpanIDs: []string{"c"},
|
||||
wantPrevCursor: cursorOf("c"),
|
||||
},
|
||||
{
|
||||
name: "After_ExtraRow_SetsBoth",
|
||||
query: ThreadQuery{Limit: 1, After: cursorAt("b")},
|
||||
afterRows: spans("c", "d"),
|
||||
wantResponseSpanIDs: []string{"c"},
|
||||
wantPrevCursor: cursorOf("c"),
|
||||
wantNextCursor: cursorOf("c"),
|
||||
},
|
||||
{
|
||||
name: "Before_NoExtraRow_ReversesAndSetsNextOnly",
|
||||
query: ThreadQuery{Limit: 2, Before: cursorAt("c")},
|
||||
beforeRows: spans("b", "a"),
|
||||
wantResponseSpanIDs: []string{"a", "b"},
|
||||
wantNextCursor: cursorOf("b"),
|
||||
},
|
||||
{
|
||||
name: "Before_ExtraRow_KeepsNearestAndSetsBoth",
|
||||
query: ThreadQuery{Limit: 2, Before: cursorAt("d")},
|
||||
beforeRows: spans("c", "b", "a"),
|
||||
wantResponseSpanIDs: []string{"b", "c"},
|
||||
wantPrevCursor: cursorOf("b"),
|
||||
wantNextCursor: cursorOf("c"),
|
||||
},
|
||||
{
|
||||
name: "SpanID_BothSidesFull_SplitsLimitInHalf",
|
||||
query: ThreadQuery{Limit: 4, SpanID: "d"},
|
||||
beforeRows: spans("c", "b", "a"),
|
||||
afterRows: spans("d", "e", "f"),
|
||||
wantResponseSpanIDs: []string{"b", "c", "d", "e"},
|
||||
wantPrevCursor: cursorOf("b"),
|
||||
wantNextCursor: cursorOf("e"),
|
||||
},
|
||||
{
|
||||
name: "SpanID_NothingBefore_AfterFillsPage",
|
||||
query: ThreadQuery{Limit: 4, SpanID: "a"},
|
||||
afterRows: spans("a", "b", "c", "d"),
|
||||
wantResponseSpanIDs: []string{"a", "b", "c", "d"},
|
||||
},
|
||||
{
|
||||
name: "SpanID_OnlyAnchorAfter_BeforeFillsPage",
|
||||
query: ThreadQuery{Limit: 4, SpanID: "d"},
|
||||
beforeRows: spans("c", "b", "a"),
|
||||
afterRows: spans("d"),
|
||||
wantResponseSpanIDs: []string{"a", "b", "c", "d"},
|
||||
},
|
||||
{
|
||||
name: "SpanID_LimitOne_KeepsAnchor",
|
||||
query: ThreadQuery{Limit: 1, SpanID: "b"},
|
||||
beforeRows: spans("a"),
|
||||
afterRows: spans("b", "c"),
|
||||
wantResponseSpanIDs: []string{"b"},
|
||||
wantPrevCursor: cursorOf("b"),
|
||||
wantNextCursor: cursorOf("b"),
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
thread := NewGettableTraceThread("trace-1", &testCase.query, testCase.beforeRows, testCase.afterRows)
|
||||
require.NotNil(t, thread.Spans)
|
||||
spanIDs := make([]string, len(thread.Spans))
|
||||
for i, span := range thread.Spans {
|
||||
spanIDs[i] = span.SpanID
|
||||
assert.Equal(t, "trace-1", span.TraceID)
|
||||
}
|
||||
assert.Equal(t, testCase.wantResponseSpanIDs, spanIDs)
|
||||
assert.Equal(t, testCase.wantPrevCursor, thread.PrevCursor)
|
||||
assert.Equal(t, testCase.wantNextCursor, thread.NextCursor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewThreadSpan_TimeUnixInMillis(t *testing.T) {
|
||||
span := newThreadSpan("trace-1", &StorableSpan{SpanID: "a", StartTime: time.Unix(1, 500_000_000)})
|
||||
assert.Equal(t, uint64(1500), span.TimeUnix)
|
||||
assert.Equal(t, ThreadCursor{TimeUnixNano: 1_500_000_000, SpanID: "a"}, span.cursor())
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
@@ -94,36 +93,35 @@ type WaterfallSpan struct {
|
||||
|
||||
// StorableSpan is the ClickHouse scan struct for the v3 waterfall query.
|
||||
type StorableSpan struct {
|
||||
StartTime time.Time `ch:"timestamp"`
|
||||
DurationNano uint64 `ch:"duration_nano"`
|
||||
SpanID string `ch:"span_id"`
|
||||
HasError bool `ch:"has_error"`
|
||||
Kind int8 `ch:"kind"`
|
||||
ServiceName string `ch:"resource_string_service$$name"`
|
||||
Name string `ch:"name"`
|
||||
AttributesString map[string]string `ch:"attributes_string"`
|
||||
AttributesNumber map[string]float64 `ch:"attributes_number"`
|
||||
AttributesBool map[string]bool `ch:"attributes_bool"`
|
||||
AttributesJSON telemetrystoretypes.JSONValue `ch:"attributes"`
|
||||
ResourcesString map[string]string `ch:"resources_string"`
|
||||
Events []string `ch:"events"`
|
||||
StatusMessage string `ch:"status_message"`
|
||||
StatusCodeString string `ch:"status_code_string"`
|
||||
SpanKind string `ch:"kind_string"`
|
||||
ParentSpanID string `ch:"parent_span_id"`
|
||||
Flags uint32 `ch:"flags"`
|
||||
IsRemote string `ch:"is_remote"`
|
||||
TraceState string `ch:"trace_state"`
|
||||
StatusCode int16 `ch:"status_code"`
|
||||
DBName string `ch:"db_name"`
|
||||
DBOperation string `ch:"db_operation"`
|
||||
HTTPMethod string `ch:"http_method"`
|
||||
HTTPURL string `ch:"http_url"`
|
||||
HTTPHost string `ch:"http_host"`
|
||||
ExternalHTTPMethod string `ch:"external_http_method"`
|
||||
ExternalHTTPURL string `ch:"external_http_url"`
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
References string `ch:"references"`
|
||||
StartTime time.Time `ch:"timestamp"`
|
||||
DurationNano uint64 `ch:"duration_nano"`
|
||||
SpanID string `ch:"span_id"`
|
||||
HasError bool `ch:"has_error"`
|
||||
Kind int8 `ch:"kind"`
|
||||
ServiceName string `ch:"resource_string_service$$name"`
|
||||
Name string `ch:"name"`
|
||||
AttributesString map[string]string `ch:"attributes_string"`
|
||||
AttributesNumber map[string]float64 `ch:"attributes_number"`
|
||||
AttributesBool map[string]bool `ch:"attributes_bool"`
|
||||
ResourcesString map[string]string `ch:"resources_string"`
|
||||
Events []string `ch:"events"`
|
||||
StatusMessage string `ch:"status_message"`
|
||||
StatusCodeString string `ch:"status_code_string"`
|
||||
SpanKind string `ch:"kind_string"`
|
||||
ParentSpanID string `ch:"parent_span_id"`
|
||||
Flags uint32 `ch:"flags"`
|
||||
IsRemote string `ch:"is_remote"`
|
||||
TraceState string `ch:"trace_state"`
|
||||
StatusCode int16 `ch:"status_code"`
|
||||
DBName string `ch:"db_name"`
|
||||
DBOperation string `ch:"db_operation"`
|
||||
HTTPMethod string `ch:"http_method"`
|
||||
HTTPURL string `ch:"http_url"`
|
||||
HTTPHost string `ch:"http_host"`
|
||||
ExternalHTTPMethod string `ch:"external_http_method"`
|
||||
ExternalHTTPURL string `ch:"external_http_url"`
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
References string `ch:"references"`
|
||||
}
|
||||
|
||||
// MinimalSpan with only the fields needed to build the parent-child tree.
|
||||
@@ -279,11 +277,8 @@ func (item *StorableSpan) AttributeValue(name string) any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Attributes merges the JSON column (selected only by the thread query) with the legacy maps;
|
||||
// JSON is flattened first, so the legacy maps win on collision.
|
||||
func (item *StorableSpan) Attributes() map[string]any {
|
||||
attributes := make(map[string]any, len(item.AttributesString)+len(item.AttributesNumber)+len(item.AttributesBool)+len(item.AttributesJSON))
|
||||
item.AttributesJSON.FlattenInto("", attributes)
|
||||
attributes := make(map[string]any, len(item.AttributesString)+len(item.AttributesNumber)+len(item.AttributesBool))
|
||||
for k, v := range item.AttributesString {
|
||||
attributes[k] = v
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStorableSpanAttributes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
span StorableSpan
|
||||
wantAttrs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "LegacyMapOnly_Kept",
|
||||
span: StorableSpan{AttributesString: map[string]string{
|
||||
"gen_ai.input.messages": `[{"role":"user","parts":[{"type":"text","content":"hi"}]}]`,
|
||||
"gen_ai.output.messages": `[{"role":"assistant","parts":[{"type":"text","content":"hello"}],"finish_reason":"stop"}]`,
|
||||
}},
|
||||
wantAttrs: map[string]any{
|
||||
"gen_ai.input.messages": `[{"role":"user","parts":[{"type":"text","content":"hi"}]}]`,
|
||||
"gen_ai.output.messages": `[{"role":"assistant","parts":[{"type":"text","content":"hello"}],"finish_reason":"stop"}]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "JSONColumn_FlattenedToDottedKeys",
|
||||
span: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{
|
||||
"gen_ai": map[string]any{
|
||||
"input": map[string]any{"messages": `[{"role":"user","content":"hi"}]`},
|
||||
"request": map[string]any{"model": "gpt-4o"},
|
||||
},
|
||||
}},
|
||||
wantAttrs: map[string]any{
|
||||
"gen_ai.input.messages": `[{"role":"user","content":"hi"}]`,
|
||||
"gen_ai.request.model": "gpt-4o",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LegacyMapWinsOverJSONColumn",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"request": map[string]any{"model": "json"}}},
|
||||
AttributesString: map[string]string{"gen_ai.request.model": "map"},
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.request.model": "map"},
|
||||
},
|
||||
{
|
||||
name: "NoMessages_AttributesKept",
|
||||
span: StorableSpan{AttributesString: map[string]string{"http.method": "GET"}},
|
||||
wantAttrs: map[string]any{"http.method": "GET"},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.wantAttrs, testCase.span.Attributes())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -35,21 +35,3 @@ func (v *JSONValue) Scan(src any) error {
|
||||
*v = decoded
|
||||
return nil
|
||||
}
|
||||
|
||||
// FlattenInto writes v into out under dotted keys, overwriting existing keys.
|
||||
func (v JSONValue) FlattenInto(prefix string, out map[string]any) {
|
||||
for k, value := range v {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch child := value.(type) {
|
||||
case map[string]any:
|
||||
JSONValue(child).FlattenInto(key, out)
|
||||
case JSONValue:
|
||||
child.FlattenInto(key, out)
|
||||
default:
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,18 +76,18 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/goleak v1.3.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.83.1 // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -377,29 +377,29 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
|
||||
@@ -409,8 +409,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
|
||||
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind
|
||||
|
||||
|
||||
def test_thread_returns_message_spans_in_order(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id, first_llm_id, tool_id, second_llm_id, third_llm_id = (TraceIdGenerator.span_id() for _ in range(5))
|
||||
resources = {"service.name": "tracedetail-thread"}
|
||||
first_input = json.dumps([{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}])
|
||||
first_output = json.dumps([{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": {"city": "Bangalore"}}], "finish_reason": "tool_call"}])
|
||||
second_input = json.dumps([{"role": "tool", "content": "sunny", "tool_call_id": "call_1"}])
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=9), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=8), trace_id=trace_id, span_id=first_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": first_input, "gen_ai.output.messages": first_output}, attribute_write_mode="json_only"
|
||||
),
|
||||
Traces(timestamp=now - timedelta(seconds=6), trace_id=trace_id, span_id=tool_id, parent_span_id=root_id, name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="json_only"),
|
||||
Traces(timestamp=now - timedelta(seconds=4), trace_id=trace_id, span_id=second_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": second_input}, attribute_write_mode="json_only"),
|
||||
Traces(timestamp=now - timedelta(seconds=2), trace_id=trace_id, span_id=third_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": "It is sunny in Bangalore."}, attribute_write_mode="json_only"),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread"), headers={"Authorization": f"Bearer {token}"}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
thread = response.json()["data"]
|
||||
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, second_llm_id, third_llm_id]
|
||||
assert "nextCursor" not in thread
|
||||
|
||||
first, input_only, output_only = thread["spans"]
|
||||
assert first["time_unix"] == int((now - timedelta(seconds=8)).timestamp() * 1000)
|
||||
assert first["attributes"]["gen_ai.input.messages"] == first_input
|
||||
assert first["attributes"]["gen_ai.request.model"] == "gpt-4o"
|
||||
assert first["attributes"]["gen_ai.output.messages"] == first_output
|
||||
assert input_only["attributes"]["gen_ai.input.messages"] == second_input
|
||||
assert "gen_ai.output.messages" not in input_only["attributes"]
|
||||
assert "gen_ai.input.messages" not in output_only["attributes"]
|
||||
assert output_only["attributes"]["gen_ai.output.messages"] == "It is sunny in Bangalore."
|
||||
|
||||
|
||||
def test_thread_paginates_with_cursors(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
span_ids = [TraceIdGenerator.span_id() for _ in range(3)]
|
||||
# identical timestamps on the last two exercise the span_id tie-break
|
||||
timestamps = [now - timedelta(seconds=6), now - timedelta(seconds=3), now - timedelta(seconds=3)]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-pages"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
|
||||
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
|
||||
]
|
||||
)
|
||||
expected_order = [span_ids[0], *sorted(span_ids[1:])]
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def get_page(params: dict) -> dict:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
|
||||
return response.json()["data"]
|
||||
|
||||
first = get_page({"limit": 2})
|
||||
assert [span["span_id"] for span in first["spans"]] == expected_order[:2]
|
||||
assert "prevCursor" not in first
|
||||
assert first["nextCursor"]
|
||||
|
||||
last = get_page({"limit": 2, "after": first["nextCursor"]})
|
||||
assert [span["span_id"] for span in last["spans"]] == expected_order[2:]
|
||||
assert last["prevCursor"]
|
||||
assert "nextCursor" not in last
|
||||
|
||||
previous = get_page({"limit": 2, "before": last["prevCursor"]})
|
||||
assert [span["span_id"] for span in previous["spans"]] == expected_order[:2]
|
||||
assert "prevCursor" not in previous
|
||||
assert previous["nextCursor"] == first["nextCursor"]
|
||||
|
||||
middle = get_page({"limit": 1, "before": last["prevCursor"]})
|
||||
assert [span["span_id"] for span in middle["spans"]] == expected_order[1:2]
|
||||
assert middle["prevCursor"]
|
||||
assert middle["nextCursor"]
|
||||
|
||||
start = get_page({"limit": 2, "before": middle["prevCursor"]})
|
||||
assert [span["span_id"] for span in start["spans"]] == expected_order[:1]
|
||||
assert "prevCursor" not in start
|
||||
|
||||
|
||||
def test_thread_paginates_across_buckets(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
bucket = now.replace(minute=0 if now.minute < 30 else 30, second=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
# neighbours within one 30-minute ts_bucket_start and across bucket boundaries
|
||||
timestamps = [
|
||||
bucket - timedelta(minutes=59, seconds=59),
|
||||
bucket - timedelta(minutes=30, seconds=1),
|
||||
bucket - timedelta(minutes=30),
|
||||
bucket - timedelta(seconds=1),
|
||||
bucket,
|
||||
]
|
||||
span_ids = [TraceIdGenerator.span_id() for _ in timestamps]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-buckets"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
|
||||
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def get_page(params: dict) -> dict:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
|
||||
return response.json()["data"]
|
||||
|
||||
page = get_page({"limit": 1})
|
||||
forward = [span["span_id"] for span in page["spans"]]
|
||||
while "nextCursor" in page:
|
||||
page = get_page({"limit": 1, "after": page["nextCursor"]})
|
||||
forward += [span["span_id"] for span in page["spans"]]
|
||||
assert forward == span_ids
|
||||
|
||||
backward = [span["span_id"] for span in page["spans"]]
|
||||
while "prevCursor" in page:
|
||||
page = get_page({"limit": 1, "before": page["prevCursor"]})
|
||||
backward = [span["span_id"] for span in page["spans"]] + backward
|
||||
assert backward == span_ids
|
||||
|
||||
for index in range(1, len(span_ids)):
|
||||
around = get_page({"limit": 2, "spanId": span_ids[index]})
|
||||
assert [span["span_id"] for span in around["spans"]] == span_ids[index - 1 : index + 1], index
|
||||
|
||||
|
||||
def test_thread_opens_around_span(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
resources = {"service.name": "tracedetail-thread-anchor"}
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
llm_ids = [TraceIdGenerator.span_id() for _ in range(5)]
|
||||
tool_id = TraceIdGenerator.span_id()
|
||||
# tool span sits between the third and fourth llm spans
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=19), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
|
||||
*(
|
||||
Traces(timestamp=now - timedelta(seconds=18 - 3 * i), trace_id=trace_id, span_id=span_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
|
||||
for i, span_id in enumerate(llm_ids)
|
||||
),
|
||||
Traces(timestamp=now - timedelta(seconds=11), trace_id=trace_id, span_id=tool_id, parent_span_id=root_id, name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="json_only"),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def get_page(params: dict) -> dict:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
|
||||
return response.json()["data"]
|
||||
|
||||
# span with messages: included with its neighbours
|
||||
around = get_page({"limit": 3, "spanId": llm_ids[2]})
|
||||
assert [span["span_id"] for span in around["spans"]] == llm_ids[1:4]
|
||||
assert around["prevCursor"]
|
||||
assert around["nextCursor"]
|
||||
assert [span["span_id"] for span in get_page({"limit": 3, "before": around["prevCursor"]})["spans"]] == llm_ids[:1]
|
||||
assert [span["span_id"] for span in get_page({"limit": 3, "after": around["nextCursor"]})["spans"]] == llm_ids[4:]
|
||||
|
||||
# span without messages: only its neighbours
|
||||
around_tool = get_page({"limit": 2, "spanId": tool_id})
|
||||
assert [span["span_id"] for span in around_tool["spans"]] == llm_ids[2:4]
|
||||
assert around_tool["prevCursor"]
|
||||
assert around_tool["nextCursor"]
|
||||
|
||||
# near the start, the short side gives its room to the other
|
||||
at_start = get_page({"limit": 3, "spanId": llm_ids[0]})
|
||||
assert [span["span_id"] for span in at_start["spans"]] == llm_ids[:3]
|
||||
assert "prevCursor" not in at_start
|
||||
assert at_start["nextCursor"]
|
||||
|
||||
# near the end
|
||||
at_end = get_page({"limit": 3, "spanId": llm_ids[4]})
|
||||
assert [span["span_id"] for span in at_end["spans"]] == llm_ids[2:]
|
||||
assert at_end["prevCursor"]
|
||||
assert "nextCursor" not in at_end
|
||||
|
||||
# page covers the whole thread
|
||||
whole = get_page({"limit": 10, "spanId": root_id})
|
||||
assert [span["span_id"] for span in whole["spans"]] == llm_ids
|
||||
assert "prevCursor" not in whole
|
||||
assert "nextCursor" not in whole
|
||||
|
||||
missing = requests.get(url, params={"spanId": TraceIdGenerator.span_id()}, headers=headers, timeout=10)
|
||||
assert missing.status_code == HTTPStatus.NOT_FOUND, missing.text
|
||||
|
||||
|
||||
def test_thread_without_messages_is_empty(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
insert_traces([Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=5), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="GET /health", resources={"service.name": "tracedetail-thread-empty"}, attribute_write_mode="json_only")])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread"), headers={"Authorization": f"Bearer {token}"}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"] == {"spans": []}
|
||||
|
||||
|
||||
def test_thread_rejects_invalid_requests(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
insert_traces([Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=5), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="chat gpt-4o", resources={"service.name": "tracedetail-thread-invalid"}, attributes={"gen_ai.input.messages": "hi"}, attribute_write_mode="json_only")])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
|
||||
|
||||
cursor = base64.urlsafe_b64encode(json.dumps({"t": 1, "s": "f1fa1bc863e94dd0"}).encode()).decode().rstrip("=")
|
||||
for params in (
|
||||
{"limit": -1},
|
||||
{"limit": 1001},
|
||||
{"after": "not-a-cursor"},
|
||||
{"before": "not-a-cursor"},
|
||||
{"after": cursor, "before": cursor},
|
||||
{"after": cursor, "spanId": "f1fa1bc863e94dd0"},
|
||||
{"before": cursor, "spanId": "f1fa1bc863e94dd0"},
|
||||
):
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, f"{params}: {response.text}"
|
||||
|
||||
missing = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{TraceIdGenerator.trace_id()}/thread"), headers=headers, timeout=10)
|
||||
assert missing.status_code == HTTPStatus.NOT_FOUND, missing.text
|
||||
Reference in New Issue
Block a user