Compare commits

..

8 Commits

Author SHA1 Message Date
Gaurav Tewari
084d9aad91 refactor: trace details and and header changes 2026-10-06 22:29:02 +05:30
Gaurav Tewari
2466aa166a feat: trace detail header & tooltip changes 2026-10-06 21:54:29 +05:30
Gaurav Tewari
82a588c15b feat: update badge color 2026-10-06 19:24:48 +05:30
Gaurav Tewari
13be8de648 feat: add support for badge and update ai icons 2026-10-06 19:01:40 +05:30
Gaurav Tewari
58bb656a11 chore: update tooltip design and support ai spans 2026-10-06 18:42:47 +05:30
Gaurav Tewari
f6f95d4f7c chore: another refactor approch 2026-10-06 15:50:13 +05:30
Gaurav Tewari
528cb41551 feat: add trace tab and initial refactor 2026-10-06 14:48:35 +05:30
dependabot[bot]
185677634c chore(deps): bump google.golang.org/grpc from 1.83.1 to 1.83.2 in /scripts/promqltestcorpus (#12804)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from
1.83.1 to 1.83.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's
releases</a>.</em></p>
<blockquote>
<h2>Release 1.83.2</h2>
<h1>Security</h1>
<ul>
<li>server: Reject requests missing both <code>:authority</code> and
<code>Host</code> headers with HTTP 400 and status
<code>Internal</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/pull/9365">grpc/grpc-go#9365</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/winklemad"><code>@​winklemad</code></a></li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="030ee8becb"><code>030ee8b</code></a>
Update version to 1.83.2 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9375">#9375</a>)</li>
<li><a
href="8668b69c16"><code>8668b69</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9365">#9365</a> to
v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9366">#9366</a>)</li>
<li><a
href="a3e952d2b7"><code>a3e952d</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9346">#9346</a> to
v1.83.x and update x/net dependency (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9369">#9369</a>)</li>
<li><a
href="58f8fd9a00"><code>58f8fd9</code></a>
Change version to 1.83.2-dev (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9337">#9337</a>)</li>
<li>See full diff in <a
href="https://github.com/grpc/grpc-go/compare/v1.83.1...v1.83.2">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 13:04:53 +00:00
131 changed files with 1687 additions and 5125 deletions

View File

@@ -68,7 +68,6 @@ jobs:
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -1,44 +1,5 @@
components:
schemas:
AiobservabilitytypesMessage:
properties:
content:
items:
$ref: '#/components/schemas/AiobservabilitytypesPart'
type: array
finishReason:
type: string
role:
type: string
required:
- content
type: object
AiobservabilitytypesPart:
properties:
arguments: {}
content:
type: string
id:
type: string
isError:
type: boolean
name:
type: string
toolCallId:
type: string
type:
$ref: '#/components/schemas/AiobservabilitytypesPartType'
required:
- type
type: object
AiobservabilitytypesPartType:
enum:
- text
- thinking
- tool_call
- tool_result
- generic
type: string
AlertmanagertypesChannel:
properties:
createdAt:
@@ -9732,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:
@@ -10059,92 +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
formatted_input:
items:
$ref: '#/components/schemas/AiobservabilitytypesMessage'
type: array
formatted_output:
items:
$ref: '#/components/schemas/AiobservabilitytypesMessage'
type: array
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:
@@ -15869,90 +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, each with the messages normalised into formatted_input and formatted_output.
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

View File

@@ -4,53 +4,6 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
export enum AiobservabilitytypesPartTypeDTO {
text = 'text',
thinking = 'thinking',
tool_call = 'tool_call',
tool_result = 'tool_result',
generic = 'generic',
}
export interface AiobservabilitytypesPartDTO {
arguments?: unknown;
/**
* @type string
*/
content?: string;
/**
* @type string
*/
id?: string;
/**
* @type boolean
*/
isError?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
toolCallId?: string;
type: AiobservabilitytypesPartTypeDTO;
}
export interface AiobservabilitytypesMessageDTO {
/**
* @type array
*/
content: AiobservabilitytypesPartDTO[];
/**
* @type string
*/
finishReason?: string;
/**
* @type string
*/
role?: string;
}
export interface AlertmanagertypesChannelDTO {
/**
* @type string
@@ -11256,169 +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 array
*/
formatted_input?: AiobservabilitytypesMessageDTO[];
/**
* @type array
*/
formatted_output?: AiobservabilitytypesMessageDTO[];
/**
* @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;
};
@@ -13092,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

View File

@@ -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, each with the messages normalised into formatted_input and formatted_output. 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

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});
});

View 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)' },
};

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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();
});
});

View 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 })}`;
}

View File

@@ -34,6 +34,15 @@
}
}
.tabs {
flex-shrink: 0;
}
.tab {
flex: unset;
padding: 0 24px;
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -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}

View File

@@ -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;

View File

@@ -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',
);
});
});

View File

@@ -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,
},
};

View File

@@ -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;
};
}

View File

@@ -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 };
}

View File

@@ -1,10 +1,3 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.layoutRow {
display: flex;
flex: 1;

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
function TraceDetailsThread(): JSX.Element {
return <div data-testid="trace-details-thread" />;
}
export default TraceDetailsThread;

View File

@@ -0,0 +1,6 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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),

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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>
);
})}

View File

@@ -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',
}

View File

@@ -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,
),
);
}

View File

@@ -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>
);
}

View File

@@ -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]);

View File

@@ -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: {

View File

@@ -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);
});
});

View 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;
}

View File

@@ -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, each with the messages normalised into formatted_input and formatted_output. 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
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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

View File

@@ -1,41 +0,0 @@
package genaimessages
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testdata/captured/<sdk>/<attribute>.txt holds a real attribute value from the scripts
// repo's static-telemetry-generator captures; <attribute>.want.json is its reviewed output.
func TestNormalizeCapturedPayloads(t *testing.T) {
inputs, err := filepath.Glob(filepath.Join("testdata", "captured", "*", "*.txt"))
require.NoError(t, err)
require.NotEmpty(t, inputs)
for _, input := range inputs {
sdk := filepath.Base(filepath.Dir(input))
attribute := strings.TrimSuffix(filepath.Base(input), ".txt")
t.Run(sdk+"/"+attribute, func(t *testing.T) {
raw, err := os.ReadFile(input)
require.NoError(t, err)
wantJSON, err := os.ReadFile(strings.TrimSuffix(input, ".txt") + ".want.json")
require.NoError(t, err)
var want []aiobservabilitytypes.Message
require.NoError(t, json.Unmarshal(wantJSON, &want))
got, err := json.Marshal(Normalize(string(raw)))
require.NoError(t, err)
var gotRoundTrip []aiobservabilitytypes.Message
require.NoError(t, json.Unmarshal(got, &gotRoundTrip))
assert.Equal(t, want, gotRoundTrip)
})
}
}

View File

@@ -1,307 +0,0 @@
package genaimessages
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
// convertSemconvMessages handles [{role, parts, finish_reason}] and Gemini contents.
func convertSemconvMessages(value any) ([]aiobservabilitytypes.Message, bool) {
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, false
}
first, ok := toObject(firstItem(list))
if !ok {
return nil, false
}
if ok := first.has("parts"); !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(list))
for _, item := range list {
m, ok := toObject(item)
if !ok {
messages = append(messages, genericMessages(stringOf(item))...)
continue
}
messages = append(messages, semconvMessage(m, "")...)
}
return messages, true
}
// convertChatMessageList handles OpenAI, Anthropic, Vercel and LangChain message lists.
func convertChatMessageList(value any) ([]aiobservabilitytypes.Message, bool) {
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, false
}
if !isChatMessage(firstItem(list)) {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(list))
for _, item := range list {
messages = append(messages, chatMessage(item, "")...)
}
return messages, true
}
func isChatMessage(value any) bool {
m, ok := toObject(value)
if !ok {
return false
}
if ok := m.has("role"); ok {
return true
}
switch typ := m.str("type"); typ {
case "message", "reasoning", "human", "ai", "tool", "system":
return true
case "constructor":
ok := m.has("kwargs")
return ok
default:
return isResponsesItemType(typ)
}
}
// convertToolCallList handles a bare tool call list, e.g. Vercel ai.response.toolCalls.
func convertToolCallList(value any) ([]aiobservabilitytypes.Message, bool) {
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, false
}
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: make([]aiobservabilitytypes.Part, 0, len(list))}
for _, item := range list {
call, ok := toObject(item)
if !ok || !isToolCall(call) {
return nil, false
}
msg.Content = append(msg.Content, toolCallPart(call))
}
return []aiobservabilitytypes.Message{msg}, true
}
// isToolCall rejects tool definitions, which carry no arguments.
func isToolCall(call object) bool {
if has := call.has("toolName"); has {
return true
}
if fn, ok := call.obj("function"); ok {
has := fn.has("arguments")
return has
}
if has := call.has("name"); !has {
return false
}
_, has := call.lookup("arguments", "args")
return has
}
// convertChatRequest handles OpenAI, Anthropic, Vercel, Gemini and LangChain request objects.
func convertChatRequest(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
conversation, ok := m.lookup("messages", "input", "contents", "prompt")
if !ok {
return nil, false
}
messages := []aiobservabilitytypes.Message{}
if system := systemMessage(m.first("system", "instructions", "system_instruction", "systemInstruction", "system_prompt")); system != nil {
messages = append(messages, *system)
} else if config, ok := m.obj("config"); ok {
if system := systemMessage(config.first("system_instruction", "systemInstruction")); system != nil {
messages = append(messages, *system)
}
}
// {messages: "[...]"}: the list arrives JSON-encoded once more from some SDKs
if s, isString := conversation.(string); isString {
var decoded any
if err := json.Unmarshal([]byte(s), &decoded); err == nil {
if _, isList := decoded.([]any); isList {
conversation = decoded
}
}
}
switch c := conversation.(type) {
case string:
if c != "" {
messages = append(messages, textMessage(aiobservabilitytypes.MessageRoleUser, c))
}
case []any:
for _, item := range flattenOnce(c) {
if s, isString := item.(string); isString {
messages = append(messages, textMessage(aiobservabilitytypes.MessageRoleUser, s))
continue
}
messages = append(messages, semconvMessage(asObject(item), aiobservabilitytypes.MessageRoleUser)...)
}
case map[string]any:
messages = append(messages, semconvMessage(c, aiobservabilitytypes.MessageRoleUser)...)
default:
return nil, false
}
return messages, true
}
// systemMessage returns nil when value is empty or unknown.
func systemMessage(value any) *aiobservabilitytypes.Message {
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleSystem, Content: []aiobservabilitytypes.Part{}}
switch v := value.(type) {
case string:
if v == "" {
return nil
}
msg.Content = append(msg.Content, textPart(v))
case []any:
for _, item := range v {
msg.Content = append(msg.Content, contentBlockPart(item))
}
case map[string]any:
parts, ok := object(v).list("parts")
if !ok {
return nil
}
for _, p := range parts {
msg.Content = append(msg.Content, semconvPart(p))
}
default:
return nil
}
if len(msg.Content) == 0 {
return nil
}
return &msg
}
// convertChatResponse handles the OpenAI Chat Completions {choices} response.
func convertChatResponse(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
choices, ok := m.list("choices")
if !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(choices))
for _, c := range choices {
choice := asObject(c)
var converted []aiobservabilitytypes.Message
if message, ok := toObject(choice.first("message", "delta")); ok {
converted = chatMessage(message, aiobservabilitytypes.MessageRoleAssistant)
} else if text, ok := choice.get("text"); ok {
converted = []aiobservabilitytypes.Message{textMessage(aiobservabilitytypes.MessageRoleAssistant, stringOf(text))}
} else {
converted = []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{genericPart(choice)}}}
}
messages = append(messages, withFinishReason(converted, choice.str("finish_reason"))...)
}
return messages, true
}
func convertResponsesAPIResponse(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
output, ok := m.list("output")
if !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(output))
for _, item := range output {
messages = append(messages, chatMessage(item, aiobservabilitytypes.MessageRoleAssistant)...)
}
if last := lastMessage(messages); last != nil && last.FinishReason == "" {
if details, ok := m.obj("incomplete_details"); ok {
last.FinishReason = normalizeFinishReason(details.str("reason"))
} else if m.str("status") == "completed" {
last.FinishReason = aiobservabilitytypes.FinishReasonStop
}
}
return messages, true
}
// convertGeminiResponse handles Gemini {candidates} and Google ADK {content} responses.
func convertGeminiResponse(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
if content, ok := m.obj("content"); ok {
if hasParts := content.has("parts"); hasParts {
return withFinishReason(semconvMessage(content, aiobservabilitytypes.MessageRoleAssistant), finishReasonOf(m)), true
}
}
candidates, ok := m.list("candidates")
if !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(candidates))
for _, c := range candidates {
candidate := asObject(c)
content, ok := candidate.obj("content")
if !ok {
generic := []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{genericPart(candidate)}}}
messages = append(messages, withFinishReason(generic, finishReasonOf(candidate))...)
continue
}
messages = append(messages, withFinishReason(semconvMessage(content, aiobservabilitytypes.MessageRoleAssistant), finishReasonOf(candidate))...)
}
return messages, true
}
func convertLegacyCompletion(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
completion, ok := m.text("completion")
if !ok {
return nil, false
}
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{}}
if reasoning, ok := m.text("reasoning"); ok && reasoning != "" {
msg.Content = append(msg.Content, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: reasoning})
}
msg.Content = append(msg.Content, textPart(completion))
return []aiobservabilitytypes.Message{msg}, true
}
func convertLangChainGenerations(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
generations, ok := m.list("generations")
if !ok {
return nil, false
}
messages := []aiobservabilitytypes.Message{}
for _, g := range flattenOnce(generations) {
gen := asObject(g)
var converted []aiobservabilitytypes.Message
if message, ok := gen.obj("message"); ok {
converted = chatMessage(message, aiobservabilitytypes.MessageRoleAssistant)
} else {
converted = []aiobservabilitytypes.Message{textMessage(aiobservabilitytypes.MessageRoleAssistant, gen.str("text"))}
}
messages = append(messages, withFinishReason(converted, asObject(gen.at("generation_info")).str("finish_reason"))...)
}
return messages, true
}
// withFinishReason sets reason on the last message that has none.
func withFinishReason(messages []aiobservabilitytypes.Message, reason string) []aiobservabilitytypes.Message {
if last := lastMessage(messages); last != nil && last.FinishReason == "" {
last.FinishReason = normalizeFinishReason(reason)
}
return messages
}

View File

@@ -1,288 +0,0 @@
package genaimessages
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
func TestNormalizeEnvelopeRequests(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "AnthropicRequest_SystemStringBecomesSystemMessage",
raw: `{
"model": "claude",
"system": "Be brief.",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 100
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleSystem,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Be brief."},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
{
name: "GeminiRequest_ConfigSystemInstructionBecomesSystemMessage",
raw: `{
"model": "gemini-2.0",
"config": {"system_instruction": "Be brief."},
"contents": [
{"role": "user", "parts": [{"text": "Hi"}]},
{
"role": "user",
"parts": [
{
"function_response": {"name": "get_weather", "response": {"temp": 20}}
}
]
}
]
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleSystem,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Be brief."},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, Name: "get_weather", Content: `{"temp":20}`},
},
},
},
},
{
name: "GeminiRequest_SystemInstructionPartsBecomeSystemMessage",
raw: `{"system_instruction": {"parts": [{"text": "Be brief."}]}, "contents": [{"role": "user", "parts": [{"text": "Hi"}]}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleSystem,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Be brief."},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
{
name: "RequestWithMessagesAsJSONString_MessagesDecoded",
raw: `{"messages":"[{\"role\":\"user\",\"content\":\"Hi\"}]"}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
})
}
func TestNormalizeEnvelopeChatResponses(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "StreamingChunkDelta_BecomesAssistantText",
raw: `{"choices": [{"delta": {"role": "assistant", "content": "Hel"}, "finish_reason": null}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hel"},
},
},
},
},
{
name: "LegacyCompletionChoiceText_BecomesAssistantTextWithLength",
raw: `{"choices": [{"text": "Hello", "finish_reason": "length"}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonLength,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hello"},
},
},
},
},
{
name: "FilteredChoiceWithoutMessage_BecomesGenericWithContentFilter",
raw: `{"choices": [{"index": 0, "finish_reason": "content_filter"}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonContentFilter,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"finish_reason":"content_filter","index":0}`},
},
},
},
},
})
}
func TestNormalizeEnvelopeOpenAIResponses(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "ResponsesAPIResponse_MessageAndReasoningItemsConverted",
raw: `{
"object": "response",
"status": "completed",
"output": [
{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "Thinking about it"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Paris."}]
}
]
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Thinking about it"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Paris."},
},
FinishReason: aiobservabilitytypes.FinishReasonStop,
},
},
},
{
name: "ResponsesAPIResponseIncomplete_EncryptedReasoningEmptyAndFinishReasonLength",
raw: `{
"output": [
{"type": "reasoning", "content": [{"type": "reasoning_text", "text": "step 1"}]},
{"type": "reasoning", "encrypted_content": "gAAAA"}
],
"status": "incomplete",
"incomplete_details": {"reason": "max_output_tokens"}
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "step 1"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonLength,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking},
},
},
},
},
})
}
func TestNormalizeEnvelopeGemini(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "GeminiResponse_ThoughtAndFunctionCallPartsConverted",
raw: `{
"candidates": [
{
"content": {
"parts": [
{"text": "Let me check", "thought": true},
{"function_call": {"name": "get_weather", "args": {"city": "Paris"}}}
],
"role": "model"
},
"finishReason": "STOP"
}
],
"usageMetadata": {}
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonStop,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Let me check"},
{Type: aiobservabilitytypes.PartTypeToolCall, Name: "get_weather", Arguments: map[string]any{"city": "Paris"}},
},
},
},
},
{
name: "GoogleADKResponse_ContentBecomesAssistantMessageWithStop",
raw: `{"content": {"parts": [{"text": "It is 18°C and clear in Bengaluru."}], "role": "model"}, "finish_reason": "STOP"}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonStop,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "It is 18°C and clear in Bengaluru."},
},
},
},
},
{
name: "GeminiResponseBlockedBySafety_BecomesGenericWithContentFilter",
raw: `{"candidates": [{"finishReason": "SAFETY", "index": 0}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonContentFilter,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"finishReason":"SAFETY","index":0}`},
},
},
},
},
})
}
func TestNormalizeEnvelopeLangChain(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "LangChainGenerationWithOnlyText_BecomesAssistantTextWithLength",
raw: `{"generations": [[{"text": "Observability is the ability", "generation_info": {"finish_reason": "length"}}]]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonLength,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Observability is the ability"},
},
},
},
},
})
}

View File

@@ -1,71 +0,0 @@
package genaimessages
import (
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
// Ordered by specificity: earlier converters never match a later format.
var converters = []converter{
convertSemconvMessages,
convertChatMessageList,
convertToolCallList,
convertChatRequest,
convertChatResponse,
convertResponsesAPIResponse,
convertGeminiResponse,
convertLegacyCompletion,
convertLangChainGenerations,
}
// Normalize converts a gen_ai.*.messages value, a JSON string or a decoded value.
// A string that is not JSON becomes one text part; JSON in no known format becomes
// one generic part holding the original.
func Normalize(raw any) []aiobservabilitytypes.Message {
var (
value any
original string
)
switch v := raw.(type) {
case nil:
return []aiobservabilitytypes.Message{}
case string:
if strings.TrimSpace(v) == "" {
return []aiobservabilitytypes.Message{}
}
original = v
if err := json.Unmarshal([]byte(v), &value); err != nil {
return []aiobservabilitytypes.Message{{Content: []aiobservabilitytypes.Part{textPart(v)}}}
}
default:
value = v
original = stringOf(v)
}
if value == nil {
return []aiobservabilitytypes.Message{}
}
if list, ok := value.([]any); ok {
if len(list) == 0 {
return []aiobservabilitytypes.Message{}
}
// [[...]]: some SDKs wrap the conversation in one more list
if _, nested := firstItem(list).([]any); nested {
value = flattenOnce(list)
}
// ["{...}", "{...}"]: an array attribute holding one JSON message per element
if decoded, ok := decodeJSONStrings(list); ok {
value = decoded
}
}
for _, convert := range converters {
if messages, ok := convert(value); ok {
return messages
}
}
return genericMessages(original)
}
type converter func(value any) (messages []aiobservabilitytypes.Message, ok bool)

View File

@@ -1,146 +0,0 @@
package genaimessages
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/stretchr/testify/assert"
)
func TestNormalize(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "ListNestedInAnotherList_Unwrapped",
raw: `[[{"role":"user","content":"Hi"}]]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
{
name: "ListOfToolDefinitions_FallsBackToGeneric",
raw: `[{"type":"function","function":{"name":"get_weather","description":"Weather","parameters":{"type":"object"}}}]`,
want: []aiobservabilitytypes.Message{
{
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `[{"type":"function","function":{"name":"get_weather","description":"Weather","parameters":{"type":"object"}}}]`},
},
},
},
},
{
name: "ListOfMessagesAsJSONStrings_EachDecoded",
raw: []any{`{"role":"user","content":"Hi"}`, `{"role":"assistant","content":"Hello"}`},
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hello"},
},
},
},
},
{
name: "TextThatIsNotJSON_ReturnedAsOneTextPart",
raw: "Let the cost of the ball be x dollars.",
want: []aiobservabilitytypes.Message{
{
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Let the cost of the ball be x dollars."},
},
},
},
},
{
name: "JSONInNoKnownFormat_ReturnedVerbatimAsGeneric",
raw: `{"output": "{\"query\": \"SigNoz\"}", "kwargs": {"name": "search_web"}}`,
want: []aiobservabilitytypes.Message{
{
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"output": "{\"query\": \"SigNoz\"}", "kwargs": {"name": "search_web"}}`},
},
},
},
},
{
name: "AlreadyDecodedValue_ConvertedLikeJSON",
raw: []any{map[string]any{"role": "user", "content": "hi"}},
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "hi"},
},
},
},
},
{
name: "BlankString_ReturnsNoMessages",
raw: " \n\t ",
want: []aiobservabilitytypes.Message{},
},
{
name: "JSONNull_ReturnsNoMessages",
raw: `null`,
want: []aiobservabilitytypes.Message{},
},
})
}
func FuzzNormalize(f *testing.F) {
for _, seed := range []string{
`[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}]`,
`[{"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "f", "input": {}}]}]`,
`[{"role": "tool", "tool_call_id": "call_1", "content": [{"type": "text", "text": "ok"}]}]`,
`{"messages": [{"role": "user", "content": "Hi"}], "system": [{"type": "text", "text": "Be brief."}]}`,
`{"choices": [{"message": {"role": "assistant", "content": "Hi"}, "finish_reason": "stop"}]}`,
`{"output": [{"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"}, {"type": "reasoning"}]}`,
`{"candidates": [{"content": {"parts": [{"functionCall": {"name": "f", "args": {}}}], "role": "model"}}]}`,
`{"generations": [[{"text": "Hi", "message": {"lc": 1, "type": "constructor", "id": ["AIMessage"], "kwargs": {}}}]]}`,
`[[{"role": "user", "content": "Hi"}]]`,
`["{\"role\": \"user\", \"content\": \"Hi\"}"]`,
``, `null`, `{}`, `[]`, `[{}]`, `"text"`, `42`,
} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, raw string) {
inputs := []any{raw}
var decoded any
if json.Unmarshal([]byte(raw), &decoded) == nil {
inputs = append(inputs, decoded)
}
for _, input := range inputs {
for i, message := range Normalize(input) {
if message.Content == nil {
t.Fatalf("message %d has nil content for %#v", i, input)
}
}
}
})
}
type normalizeCase struct {
name string
raw any
want []aiobservabilitytypes.Message
}
func assertNormalize(t *testing.T, testCases []normalizeCase) {
t.Helper()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, Normalize(testCase.raw))
})
}
}

View File

@@ -1,254 +0,0 @@
package genaimessages
import (
"cmp"
"strings"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
// chatMessage returns nil for LangGraph tool definitions.
func chatMessage(value any, defaultRole aiobservabilitytypes.MessageRole) []aiobservabilitytypes.Message {
m, ok := toObject(value)
if !ok {
return genericMessages(stringOf(value))
}
if inner, role, ok := langChainMessage(m); ok {
return chatMessage(inner, role)
}
if isLangGraphToolDefinition(m) {
return nil
}
typ := m.str("type")
if isResponsesItemType(typ) {
return responsesItemMessages(m, typ)
}
if typ == "reasoning" {
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: reasoningParts(m)}}
}
role := cmp.Or(normalizeRole(m.str("role")), standardRole(typ), defaultRole)
msg := aiobservabilitytypes.Message{
Role: role,
Content: append(messageContentParts(m, role), messageToolCallParts(m)...),
FinishReason: normalizeFinishReason(finishReasonOf(m)),
}
if refusal := m.str("refusal"); refusal != "" {
msg.Content = append(msg.Content, textPart(refusal))
}
return []aiobservabilitytypes.Message{msg}
}
// langChainMessage unwraps a serialised message: {lc, type: "constructor", id: [..., "HumanMessage"], kwargs}.
func langChainMessage(m object) (object, aiobservabilitytypes.MessageRole, bool) {
kwargs, ok := m.obj("kwargs")
if !ok || m.str("type") != "constructor" {
return nil, "", false
}
return kwargs, cmp.Or(langChainRole(m.at("id")), standardRole(kwargs.str("type"))), true
}
func langChainRole(id any) aiobservabilitytypes.MessageRole {
path, ok := id.([]any)
if !ok || len(path) == 0 {
return ""
}
switch class := stringOf(lastItem(path)); {
case strings.HasPrefix(class, "System"):
return aiobservabilitytypes.MessageRoleSystem
case strings.HasPrefix(class, "Human"):
return aiobservabilitytypes.MessageRoleUser
case strings.HasPrefix(class, "AI"):
return aiobservabilitytypes.MessageRoleAssistant
case strings.HasPrefix(class, "Tool"), strings.HasPrefix(class, "Function"):
return aiobservabilitytypes.MessageRoleTool
}
return ""
}
// isLangGraphToolDefinition matches {role: "tool", content: {type: "function"}} without tool_call_id.
func isLangGraphToolDefinition(m object) bool {
if normalizeRole(m.str("role")) != aiobservabilitytypes.MessageRoleTool {
return false
}
if has := m.has("tool_call_id"); has {
return false
}
content, ok := m.obj("content")
if !ok || content.str("type") != "function" {
return false
}
ok = content.has("function")
return ok
}
// messageContentParts turns a tool message's text into its result.
func messageContentParts(m object, role aiobservabilitytypes.MessageRole) []aiobservabilitytypes.Part {
parts := []aiobservabilitytypes.Part{}
switch content := m.at("content").(type) {
case nil:
case string:
if role == aiobservabilitytypes.MessageRoleTool {
parts = append(parts, toolMessageResult(m, content))
} else if content != "" {
parts = append(parts, textPart(content))
}
case []any:
for _, item := range content {
part := contentBlockPart(item)
if role == aiobservabilitytypes.MessageRoleTool && part.Type == aiobservabilitytypes.PartTypeText {
part = toolMessageResult(m, part.Content)
}
parts = append(parts, part)
}
case map[string]any:
if contentParts, ok := object(content).list("parts"); ok {
for _, p := range contentParts {
parts = append(parts, semconvPart(p))
}
} else if role == aiobservabilitytypes.MessageRoleTool {
parts = append(parts, toolMessageResult(m, stringOf(content)))
} else {
parts = append(parts, genericPart(content))
}
default:
parts = append(parts, genericPart(content))
}
return parts
}
func messageToolCallParts(m object) []aiobservabilitytypes.Part {
calls, _ := m.first("tool_calls", "toolCalls").([]any)
if kwargs, ok := m.obj("additional_kwargs"); ok && len(calls) == 0 {
calls, _ = kwargs.list("tool_calls")
}
parts := make([]aiobservabilitytypes.Part, 0, len(calls)+1)
for _, call := range calls {
parts = append(parts, toolCallPart(call))
}
if call, ok := m.obj("function_call"); ok {
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolCall, Name: call.str("name"), Arguments: parseArguments(call.at("arguments"))})
}
return parts
}
func toolMessageResult(m object, content string) aiobservabilitytypes.Part {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: stringOf(m.first("tool_call_id", "toolCallId")), Name: m.str("name"), Content: content}
}
// semconvMessage falls back to chatMessage when m has no parts. LiteLLM mixes in
// OpenAI fields: tool_calls beside parts, and tool_call_id on tool messages.
func semconvMessage(m object, defaultRole aiobservabilitytypes.MessageRole) []aiobservabilitytypes.Message {
parts, ok := m.list("parts")
if !ok {
return chatMessage(m, defaultRole)
}
role := cmp.Or(normalizeRole(m.str("role")), defaultRole)
msg := aiobservabilitytypes.Message{
Role: role,
Content: []aiobservabilitytypes.Part{},
FinishReason: normalizeFinishReason(finishReasonOf(m)),
}
for _, p := range parts {
part := semconvPart(p)
if role == aiobservabilitytypes.MessageRoleTool && part.Type == aiobservabilitytypes.PartTypeText {
part = toolMessageResult(m, part.Content)
}
msg.Content = append(msg.Content, part)
}
msg.Content = append(msg.Content, messageToolCallParts(m)...)
return []aiobservabilitytypes.Message{msg}
}
// isResponsesItemType matches role-less Responses API tool and MCP items.
func isResponsesItemType(typ string) bool {
switch typ {
case "":
return false
case "function_call", "function_call_output", "tool_call", "custom_tool_call", "custom_tool_call_output",
"mcp_call", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response":
return true
}
return strings.HasSuffix(typ, "_call") || strings.HasSuffix(typ, "_call_output")
}
// responsesItemMessages turns a built-in tool item into a call, plus its result when the item carries one.
func responsesItemMessages(m object, typ string) []aiobservabilitytypes.Message {
switch typ {
case "function_call", "tool_call", "custom_tool_call":
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{{
Type: aiobservabilitytypes.PartTypeToolCall,
ID: stringOf(m.first("call_id", "id")),
Name: m.str("name"),
Arguments: parseArguments(m.first("arguments", "args", "input")),
}}}}
case "function_call_output", "custom_tool_call_output":
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleTool, Content: []aiobservabilitytypes.Part{{
Type: aiobservabilitytypes.PartTypeToolResult,
ToolCallID: stringOf(m.first("call_id", "id")),
Content: stringOf(m.first("output", "result")),
}}}}
}
id := stringOf(m.first("call_id", "id"))
if strings.HasSuffix(typ, "_output") || typ == "mcp_approval_response" {
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleTool, Content: []aiobservabilitytypes.Part{{
Type: aiobservabilitytypes.PartTypeToolResult,
ToolCallID: id,
Name: strings.TrimSuffix(typ, "_output"),
Content: stringOf(m.first("output", "result", "results")),
}}}}
}
args := make(map[string]any, len(m))
for k, v := range m {
switch k {
case "type", "id", "call_id", "status", "name", "server_label", "output", "result", "results":
default:
args[k] = v
}
}
name := stringOf(m.first("name", "server_label"))
if name == "" {
name = typ
}
call := aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolCall, ID: id, Name: name}
if arguments, ok := m.get("arguments"); ok {
call.Arguments = parseArguments(arguments)
} else if len(args) > 0 {
call.Arguments = args
}
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{call}}
if result := m.first("output", "result", "results"); result != nil {
msg.Content = append(msg.Content, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: id, Name: name, Content: stringOf(result)})
}
return []aiobservabilitytypes.Message{msg}
}
// reasoningParts returns one empty thinking part for encrypted reasoning without a summary.
func reasoningParts(m object) []aiobservabilitytypes.Part {
parts := []aiobservabilitytypes.Part{}
if summary, ok := m.list("summary"); ok {
for _, s := range summary {
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(asObject(s).first("text", "content"))})
}
}
if content, ok := m.list("content"); ok {
for _, c := range content {
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(asObject(c).first("text", "content"))})
}
}
if len(parts) == 0 {
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking})
}
return parts
}
func textMessage(role aiobservabilitytypes.MessageRole, content string) aiobservabilitytypes.Message {
return aiobservabilitytypes.Message{Role: role, Content: []aiobservabilitytypes.Part{textPart(content)}}
}
func genericMessages(content string) []aiobservabilitytypes.Message {
return []aiobservabilitytypes.Message{{Content: []aiobservabilitytypes.Part{genericPart(content)}}}
}

View File

@@ -1,257 +0,0 @@
package genaimessages
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
func TestNormalizeMessageChat(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "AnthropicMessages_ThinkingAndToolUseBlocksConverted",
raw: `[
{"role": "user", "content": "Hi"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me see"},
{"type": "redacted_thinking", "data": "x"},
{
"type": "tool_use",
"id": "toolu_1",
"name": "lookup",
"input": {"q": "a"}
}
],
"stop_reason": "tool_use"
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "found",
"is_error": true
}
]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonToolCall,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Let me see"},
{Type: aiobservabilitytypes.PartTypeThinking},
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "toolu_1", Name: "lookup", Arguments: map[string]any{"q": "a"}},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "toolu_1", Content: "found", IsError: true},
},
},
},
},
{
name: "OpenAIToolMessageTextBlocks_BecomeToolResult",
raw: `[
{
"role": "tool",
"tool_call_id": "c1",
"content": [{"type": "text", "text": "20C"}, {"type": "text", "text": "clear"}]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleTool,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "c1", Content: "20C"},
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "c1", Content: "clear"},
},
},
},
},
{
name: "OpenAIRefusal_BecomesAssistantText",
raw: `[{"role": "assistant", "content": null, "refusal": "I can't help with that."}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "I can't help with that."},
},
},
},
},
{
name: "OpenAILegacyFunctionCall_BecomesToolCall",
raw: `[{"role": "assistant", "content": null, "function_call": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, Name: "get_weather", Arguments: map[string]any{"city": "Paris"}},
},
},
},
},
})
}
func TestNormalizeMessageLangChain(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "LangChainTypedMessages_ToolCallsReadFromAdditionalKwargs",
raw: `[
{"type": "human", "content": "Weather?"},
{
"type": "ai",
"content": "",
"additional_kwargs": {
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
}
]
}
},
{
"type": "tool",
"content": "20C",
"tool_call_id": "call_1",
"name": "get_weather"
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Weather?"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "Paris"}},
},
},
{
Role: aiobservabilitytypes.MessageRoleTool,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "call_1", Name: "get_weather", Content: "20C"},
},
},
},
},
{
name: "LangGraphToolDefinitionMessage_IsDropped",
raw: `[
{
"role": "tool",
"content": {
"type": "function",
"function": {"name": "get_weather", "parameters": {}}
}
},
{"role": "user", "content": "Hi"}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
})
}
func TestNormalizeMessageOpenAIResponsesItems(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "BuiltInToolCallItem_BecomesToolCall",
raw: `{
"object": "response",
"status": "completed",
"output": [
{
"type": "web_search_call",
"id": "ws_1",
"status": "completed",
"action": {"type": "search", "query": "SigNoz"}
},
{
"type": "custom_tool_call",
"call_id": "c1",
"name": "grep",
"input": "foo"
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Found it."}]
}
]
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "ws_1", Name: "web_search_call", Arguments: map[string]any{"action": map[string]any{"type": "search", "query": "SigNoz"}}},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "c1", Name: "grep", Arguments: "foo"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Found it."},
},
FinishReason: aiobservabilitytypes.FinishReasonStop,
},
},
},
{
name: "BuiltInToolOutputItem_BecomesToolResult",
raw: `[{"type": "local_shell_call_output", "call_id": "call_2", "output": "ok"}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleTool,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "call_2", Name: "local_shell_call", Content: "ok"},
},
},
},
},
{
name: "MCPCallItemWithOutput_BecomesToolCallAndToolResult",
raw: `[{"type": "mcp_call", "id": "mcp_1", "server_label": "deepwiki", "name": "ask_question", "arguments": "{\"q\": \"What is SigNoz?\"}", "output": "An observability platform."}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "mcp_1", Name: "ask_question", Arguments: map[string]any{"q": "What is SigNoz?"}},
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "mcp_1", Name: "ask_question", Content: "An observability platform."},
},
},
},
},
})
}

View File

@@ -1,152 +0,0 @@
package genaimessages
import (
"strings"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
func contentBlockPart(value any) aiobservabilitytypes.Part {
p, ok := toObject(value)
if !ok {
if s, ok := value.(string); ok {
return textPart(s)
}
return genericPart(value)
}
typ := p.str("type")
switch typ {
case "text", "input_text", "output_text", "refusal", "summary_text":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: stringOf(p.first("text", "content", "refusal"))}
case "thinking", "reasoning":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(p.first("thinking", "text", "content", "reasoning"))}
case "redacted_thinking":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking}
case "tool-call", "tool_use", "tool_call", "function_call", "server_tool_use", "mcp_tool_use":
return toolCallPart(p)
case "tool-result", "tool_result", "function_call_output":
return toolResultBlockPart(p, p.first("result", "output", "content"))
case "":
if text, ok := p.get("text"); ok {
return textPart(stringOf(text))
}
default:
if strings.HasSuffix(typ, "_tool_result") {
return toolResultBlockPart(p, p.at("content"))
}
}
return genericPart(p)
}
func semconvPart(value any) aiobservabilitytypes.Part {
p, ok := toObject(value)
if !ok {
if s, ok := value.(string); ok {
return textPart(s)
}
return genericPart(value)
}
switch p.str("type") {
case "text":
if p.flag("thought") {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(p.first("content", "text"))}
}
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: stringOf(p.first("content", "text"))}
case "reasoning", "thinking":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(p.first("content", "thinking", "text"))}
case "redacted_thinking", "redacted_reasoning":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking}
case "tool_call":
return aiobservabilitytypes.Part{
Type: aiobservabilitytypes.PartTypeToolCall,
ID: p.str("id"),
Name: p.str("name"),
Arguments: parseArguments(p.first("arguments", "args", "input")),
}
case "tool_call_response":
return aiobservabilitytypes.Part{
Type: aiobservabilitytypes.PartTypeToolResult,
ToolCallID: p.str("id"),
Name: p.str("name"),
Content: stringOf(p.first("response", "result", "content", "output")),
IsError: boolOf(p.first("is_error", "isError")),
}
case "":
// Gemini parts carry no type; the field name is the type.
if text, ok := p.get("text"); ok {
if p.flag("thought") {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(text)}
}
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: stringOf(text)}
}
if call, ok := toObject(p.first("functionCall", "function_call")); ok {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolCall, ID: call.str("id"), Name: call.str("name"), Arguments: parseArguments(call.at("args"))}
}
if resp, ok := toObject(p.first("functionResponse", "function_response")); ok {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: resp.str("id"), Name: resp.str("name"), Content: resp.str("response")}
}
}
return genericPart(p)
}
// toolCallPart reads the OpenAI, flat, Anthropic and Vercel tool call shapes.
func toolCallPart(value any) aiobservabilitytypes.Part {
call, ok := toObject(value)
if !ok {
return genericPart(value)
}
part := aiobservabilitytypes.Part{
Type: aiobservabilitytypes.PartTypeToolCall,
ID: stringOf(call.first("toolCallId", "call_id", "id")),
Name: stringOf(call.first("toolName", "name")),
}
if fn, ok := call.obj("function"); ok {
part.Name = fn.str("name")
part.Arguments = parseArguments(fn.at("arguments"))
return part
}
part.Arguments = parseArguments(call.first("arguments", "args", "input"))
return part
}
// toolResultBlockPart unwraps the Vercel {type, value} result wrapper.
func toolResultBlockPart(p object, result any) aiobservabilitytypes.Part {
if nested, ok := toObject(result); ok && len(nested) <= 2 {
if v, ok := nested.get("value"); ok {
result = v
}
}
return aiobservabilitytypes.Part{
Type: aiobservabilitytypes.PartTypeToolResult,
ToolCallID: stringOf(p.first("toolCallId", "tool_use_id", "tool_call_id", "call_id", "id")),
Name: stringOf(p.first("toolName", "name")),
Content: toolResultText(result),
IsError: boolOf(p.first("isError", "is_error")),
}
}
// toolResultText joins a list of text blocks; anything else goes through stringOf.
func toolResultText(result any) string {
list, ok := result.([]any)
if !ok || len(list) == 0 {
return stringOf(result)
}
texts := make([]string, 0, len(list))
for _, item := range list {
block := asObject(item)
text, ok := block.text("text")
if !ok || (len(block) == 2 && block.str("type") != "text") || len(block) > 2 {
return stringOf(result)
}
texts = append(texts, text)
}
return strings.Join(texts, "\n")
}
func textPart(content string) aiobservabilitytypes.Part {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: content}
}
func genericPart(value any) aiobservabilitytypes.Part {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeGeneric, Content: stringOf(value)}
}

View File

@@ -1,130 +0,0 @@
package genaimessages
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
func TestNormalizePartContent(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "PartWithUnknownType_BecomesGeneric",
raw: `[{"role":"user","parts":[{"type":"image","url":"http://x/y.png"}]}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"type":"image","url":"http://x/y.png"}`},
},
},
},
},
})
}
func TestNormalizePartThinking(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "ThoughtReasoningAndHiddenParts_BecomeThinking",
raw: `[
{
"role": "assistant",
"parts": [
{"type": "text", "content": "Let me think", "thought": true},
{"type": "reasoning", "content": "step 1"},
{"type": "redacted_reasoning", "data": "xyz"},
{"type": "text", "content": "Answer"}
]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Let me think"},
{Type: aiobservabilitytypes.PartTypeThinking, Content: "step 1"},
{Type: aiobservabilitytypes.PartTypeThinking},
{Type: aiobservabilitytypes.PartTypeText, Content: "Answer"},
},
},
},
},
})
}
func TestNormalizePartTools(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "AnthropicServerToolUseAndWebSearchResult_BecomeToolCallAndResult",
raw: `[
{
"role": "assistant",
"content": [
{"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "SigNoz"}},
{"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{"type": "web_search_result", "title": "SigNoz", "url": "https://signoz.io"}]}
]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "srvtoolu_1", Name: "web_search", Arguments: map[string]any{"query": "SigNoz"}},
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "srvtoolu_1", Content: `[{"title":"SigNoz","type":"web_search_result","url":"https://signoz.io"}]`},
},
},
},
},
{
name: "AnthropicToolResultTextBlocks_JoinedIntoOneString",
raw: `[
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "t1",
"content": [
{"type": "text", "text": "line one"},
{"type": "text", "text": "line two"}
]
}
]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "t1", Content: "line one\nline two"},
},
},
},
},
{
name: "VercelToolResultJSONWrapper_ValueUnwrapped",
raw: `[{"role": "tool", "content": [{"type": "tool-result", "toolCallId": "call_1", "toolName": "get_weather", "result": {"type": "json", "value": {"temp_c": 18}}}]}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleTool,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "call_1", Name: "get_weather", Content: `{"temp_c":18}`},
},
},
},
},
{
name: "ToolCallWithInvalidJSONArguments_ArgumentsKeptAsString",
raw: `[{"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{city: Paris"}}]}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "call_1", Name: "get_weather", Arguments: "{city: Paris"},
},
},
},
},
})
}

View File

@@ -1,54 +0,0 @@
package genaimessages
import (
"strings"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
var finishReasonKeys = []string{"finish_reason", "finishReason", "stop_reason"}
// normalizeRole keeps an unknown role, lowercased.
func normalizeRole(role string) aiobservabilitytypes.MessageRole {
if known := standardRole(role); known != "" {
return known
}
return aiobservabilitytypes.MessageRole(strings.ToLower(strings.TrimSpace(role)))
}
// normalizeFinishReason keeps an unknown reason, lowercased.
func normalizeFinishReason(reason string) aiobservabilitytypes.FinishReason {
lowered := strings.ToLower(strings.TrimSpace(reason))
switch lowered {
case "stop", "end_turn", "stop_sequence", "completed", "complete", "eos", "finished":
return aiobservabilitytypes.FinishReasonStop
case "tool_call", "tool_calls", "tool_use", "function_call":
return aiobservabilitytypes.FinishReasonToolCall
case "length", "max_tokens", "max_output_tokens", "max_completion_tokens", "model_length":
return aiobservabilitytypes.FinishReasonLength
case "content_filter", "content_filtered", "guardrail_intervened", "safety", "refusal", "recitation", "blocklist", "prohibited_content", "spii":
return aiobservabilitytypes.FinishReasonContentFilter
case "error", "failed", "incomplete":
return aiobservabilitytypes.FinishReasonError
}
return aiobservabilitytypes.FinishReason(lowered)
}
// standardRole maps vendor role names onto MessageRole; anything else is "".
func standardRole(role string) aiobservabilitytypes.MessageRole {
switch strings.ToLower(strings.TrimSpace(role)) {
case "system", "developer":
return aiobservabilitytypes.MessageRoleSystem
case "user", "human":
return aiobservabilitytypes.MessageRoleUser
case "assistant", "ai", "model":
return aiobservabilitytypes.MessageRoleAssistant
case "tool", "function":
return aiobservabilitytypes.MessageRoleTool
}
return ""
}
func finishReasonOf(m object) string {
return stringOf(m.first(finishReasonKeys...))
}

View File

@@ -1,53 +0,0 @@
package genaimessages
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/stretchr/testify/assert"
)
func TestNormalizeRole(t *testing.T) {
testCases := []struct {
name string
roles []string
want aiobservabilitytypes.MessageRole
}{
{name: "SystemAndDeveloper_MapToSystem", roles: []string{"system", "developer", " Developer "}, want: aiobservabilitytypes.MessageRoleSystem},
{name: "UserAndHuman_MapToUser", roles: []string{"user", "human", "HUMAN"}, want: aiobservabilitytypes.MessageRoleUser},
{name: "AssistantAIAndModel_MapToAssistant", roles: []string{"assistant", "ai", "model"}, want: aiobservabilitytypes.MessageRoleAssistant},
{name: "ToolAndFunction_MapToTool", roles: []string{"tool", "function"}, want: aiobservabilitytypes.MessageRoleTool},
{name: "UnknownRole_KeptLowercased", roles: []string{"Narrator"}, want: "narrator"},
{name: "EmptyRole_StaysEmpty", roles: []string{"", " "}, want: ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
for _, role := range testCase.roles {
assert.Equal(t, testCase.want, normalizeRole(role), role)
}
})
}
}
func TestNormalizeFinishReason(t *testing.T) {
testCases := []struct {
name string
reasons []string
want aiobservabilitytypes.FinishReason
}{
{name: "EndOfAnswer_MapsToStop", reasons: []string{"stop", "end_turn", "stop_sequence", "completed", "complete", "eos", "finished", "STOP"}, want: aiobservabilitytypes.FinishReasonStop},
{name: "ToolCallVariants_MapToToolCall", reasons: []string{"tool_call", "tool_calls", "tool_use", "function_call"}, want: aiobservabilitytypes.FinishReasonToolCall},
{name: "TokenLimits_MapToLength", reasons: []string{"length", "max_tokens", "max_output_tokens", "max_completion_tokens", "model_length", "MAX_TOKENS"}, want: aiobservabilitytypes.FinishReasonLength},
{name: "SafetyBlocks_MapToContentFilter", reasons: []string{"content_filter", "content_filtered", "guardrail_intervened", "safety", "refusal", "recitation", "blocklist", "prohibited_content", "spii", "SAFETY"}, want: aiobservabilitytypes.FinishReasonContentFilter},
{name: "Failures_MapToError", reasons: []string{"error", "failed", "incomplete"}, want: aiobservabilitytypes.FinishReasonError},
{name: "UnknownReason_KeptLowercased", reasons: []string{"Weird"}, want: "weird"},
{name: "EmptyReason_StaysEmpty", reasons: []string{"", " "}, want: ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
for _, reason := range testCase.reasons {
assert.Equal(t, testCase.want, normalizeFinishReason(reason), reason)
}
})
}
}

View File

@@ -1 +0,0 @@
[{"role":"user","content":"What's the weather in Bengaluru? Use the tool."},{"role":"assistant","content":"","tool_calls":[{"id":"call_yGnJsGOZMnHKTA2x1XM8897b","type":"function","name":"get_current_weather","args":"{\"city\":\"Bengaluru\"}"}]},{"role":"tool","content":"{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]

View File

@@ -1,33 +0,0 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_yGnJsGOZMnHKTA2x1XM8897b",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"
}
]
}
]

View File

@@ -1 +0,0 @@
[{"role":"assistant","content":"","tool_calls":[{"id":"call_yGnJsGOZMnHKTA2x1XM8897b","type":"function","name":"get_current_weather","args":"{\"city\":\"Bengaluru\"}"}]}]

View File

@@ -1,15 +0,0 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_yGnJsGOZMnHKTA2x1XM8897b",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
}
]

View File

@@ -1 +0,0 @@
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_iXrmKomEbGycID5aX4BYrQcg", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_iXrmKomEbGycID5aX4BYrQcg", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]

View File

@@ -1,34 +0,0 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_iXrmKomEbGycID5aX4BYrQcg",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
"toolCallId": "call_iXrmKomEbGycID5aX4BYrQcg"
}
]
}
]

View File

@@ -1 +0,0 @@
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_iXrmKomEbGycID5aX4BYrQcg", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]

View File

@@ -1,16 +0,0 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_iXrmKomEbGycID5aX4BYrQcg",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -1 +0,0 @@
[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru?"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "id": "call_mnRhZ0fYlvrSujkEcdmpppNw", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_mnRhZ0fYlvrSujkEcdmpppNw", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}"}]}]

View File

@@ -1,34 +0,0 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru?"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_mnRhZ0fYlvrSujkEcdmpppNw",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}",
"toolCallId": "call_mnRhZ0fYlvrSujkEcdmpppNw"
}
]
}
]

View File

@@ -1 +0,0 @@
[{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_mnRhZ0fYlvrSujkEcdmpppNw", "name": "get_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]

View File

@@ -1,16 +0,0 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_mnRhZ0fYlvrSujkEcdmpppNw",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -1 +0,0 @@
{"generations":[[{"text":"","generation_info":{"finish_reason":"tool_calls","logprobs":null},"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","additional_kwargs":{"refusal":null},"response_metadata":{"token_usage":{"completion_tokens":17,"prompt_tokens":63,"total_tokens":80,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"model_provider":"openai","model_name":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_5feb5fe6c2","id":"chatcmpl-Dm101OqaY01WKntqj29Epz5er05ry","service_tier":"default","finish_reason":"tool_calls","logprobs":null},"type":"ai","id":"lc_run--019e844a-d27a-7c23-b1ed-a542e0f855da-0","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_nbrbkc5fom95q8uMA2bCvH6s","type":"tool_call"}],"usage_metadata":{"input_tokens":63,"output_tokens":17,"total_tokens":80,"input_token_details":{"audio":0,"cache_read":0},"output_token_details":{"audio":0,"reasoning":0}},"invalid_tool_calls":[]}}}]],"llm_output":{"token_usage":{"completion_tokens":17,"prompt_tokens":63,"total_tokens":80,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"model_provider":"openai","model_name":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_5feb5fe6c2","id":"chatcmpl-Dm101OqaY01WKntqj29Epz5er05ry","service_tier":"default"},"run":null,"type":"LLMResult"}

View File

@@ -1,16 +0,0 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_nbrbkc5fom95q8uMA2bCvH6s",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -1 +0,0 @@
{"messages":[[{"lc":1,"type":"constructor","id":["langchain","schema","messages","SystemMessage"],"kwargs":{"content":"You are a weather assistant. Call get_weather once per city.","type":"system"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","HumanMessage"],"kwargs":{"content":"What's the weather in Bengaluru?","type":"human"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","additional_kwargs":{"refusal":null},"response_metadata":{"token_usage":{"completion_tokens":17,"prompt_tokens":63,"total_tokens":80,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"model_provider":"openai","model_name":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_5feb5fe6c2","id":"chatcmpl-Dm101OqaY01WKntqj29Epz5er05ry","service_tier":"default","finish_reason":"tool_calls","logprobs":null},"type":"ai","id":"lc_run--019e844a-d27a-7c23-b1ed-a542e0f855da-0","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_nbrbkc5fom95q8uMA2bCvH6s","type":"tool_call"}],"usage_metadata":{"input_tokens":63,"output_tokens":17,"total_tokens":80,"input_token_details":{"audio":0,"cache_read":0},"output_token_details":{"audio":0,"reasoning":0}},"invalid_tool_calls":[]}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","ToolMessage"],"kwargs":{"content":"{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}","type":"tool","tool_call_id":"call_nbrbkc5fom95q8uMA2bCvH6s","status":"success"}}]]}

View File

@@ -1,43 +0,0 @@
[
{
"role": "system",
"content": [
{
"type": "text",
"content": "You are a weather assistant. Call get_weather once per city."
}
]
},
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru?"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_nbrbkc5fom95q8uMA2bCvH6s",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}",
"toolCallId": "call_nbrbkc5fom95q8uMA2bCvH6s"
}
]
}
]

View File

@@ -1 +0,0 @@
[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru? Use the tool."}]}, {"role": "assistant", "parts": [], "tool_calls": [{"function": {"arguments": "{\"city\":\"Bengaluru\"}", "name": "get_current_weather"}, "id": "call_GuU9siyYfjnx3DfHKzFbRrvD", "type": "function"}]}, {"role": "tool", "parts": [{"type": "text", "content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}], "tool_call_id": "call_GuU9siyYfjnx3DfHKzFbRrvD"}]

View File

@@ -1,34 +0,0 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_GuU9siyYfjnx3DfHKzFbRrvD",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
"toolCallId": "call_GuU9siyYfjnx3DfHKzFbRrvD"
}
]
}
]

View File

@@ -1 +0,0 @@
[{"role": "assistant", "parts": [{"type": "text", "content": "The animal in the image is a cartoon duck."}], "finish_reason": "stop"}]

View File

@@ -1,12 +0,0 @@
[
{
"role": "assistant",
"content": [
{
"type": "text",
"content": "The animal in the image is a cartoon duck."
}
],
"finishReason": "stop"
}
]

View File

@@ -1 +0,0 @@
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_itnlpgQcfe0fb0nLJym7DTnt", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_itnlpgQcfe0fb0nLJym7DTnt", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]

View File

@@ -1,34 +0,0 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_itnlpgQcfe0fb0nLJym7DTnt",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
"toolCallId": "call_itnlpgQcfe0fb0nLJym7DTnt"
}
]
}
]

View File

@@ -1 +0,0 @@
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_itnlpgQcfe0fb0nLJym7DTnt", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]

View File

@@ -1,16 +0,0 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_itnlpgQcfe0fb0nLJym7DTnt",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -1 +0,0 @@
[{"role":"system","content":"You are a weather assistant. When asked about weather, call get-weather once per city and summarise."},{"role":"user","content":[{"type":"text","text":"What is the weather in Bengaluru?"}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","args":{"city":"Bengaluru"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","result":{"city":"Bengaluru","temperatureC":18,"summary":"Clear"}}]}]

View File

@@ -1,44 +0,0 @@
[
{
"role": "system",
"content": [
{
"type": "text",
"content": "You are a weather assistant. When asked about weather, call get-weather once per city and summarise."
}
]
},
{
"role": "user",
"content": [
{
"type": "text",
"content": "What is the weather in Bengaluru?"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_526phoykytobsvLouzES805J",
"name": "getWeather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\":\"Bengaluru\",\"summary\":\"Clear\",\"temperatureC\":18}",
"name": "getWeather",
"toolCallId": "call_526phoykytobsvLouzES805J"
}
]
}
]

View File

@@ -1 +0,0 @@
The current weather in Bengaluru is clear with a temperature of 18°C.

View File

@@ -1,10 +0,0 @@
[
{
"content": [
{
"type": "text",
"content": "The current weather in Bengaluru is clear with a temperature of 18°C."
}
]
}
]

View File

@@ -1 +0,0 @@
[{"toolCallType":"function","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","args":"{\"city\":\"Bengaluru\"}"}]

View File

@@ -1,15 +0,0 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_526phoykytobsvLouzES805J",
"name": "getWeather",
"arguments": {
"city": "Bengaluru"
}
}
]
}
]

View File

@@ -1 +0,0 @@
{"include": [], "input": [{"content": "What's the weather in Bangalore right now?", "role": "user"}, {"arguments": "{\"city\":\"Bangalore\"}", "call_id": "call_MQTQ0XYL95vA5k31cbCgRt5y", "name": "get_weather", "type": "function_call", "id": "fc_0368c3e62fb9ae12006a199f7c14c481a1a22444594cc574e4", "status": "completed"}, {"call_id": "call_MQTQ0XYL95vA5k31cbCgRt5y", "output": "27C, partly cloudy, light breeze from the south.", "type": "function_call_output"}], "instructions": "You are a concise weather assistant. When asked about weather, call the get_weather tool exactly once per city, then summarise the result in one sentence.", "model": "gpt-4o-mini", "tools": [{"name": "get_weather", "parameters": {"properties": {"city": {"title": "City", "type": "string"}}, "required": ["city"], "title": "get_weather_args", "type": "object", "additionalProperties": false}, "strict": true, "type": "function", "description": "Look up the current weather for a city. Returns a short summary string."}]}

View File

@@ -1,43 +0,0 @@
[
{
"role": "system",
"content": [
{
"type": "text",
"content": "You are a concise weather assistant. When asked about weather, call the get_weather tool exactly once per city, then summarise the result in one sentence."
}
]
},
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bangalore right now?"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_MQTQ0XYL95vA5k31cbCgRt5y",
"name": "get_weather",
"arguments": {
"city": "Bangalore"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "27C, partly cloudy, light breeze from the south.",
"toolCallId": "call_MQTQ0XYL95vA5k31cbCgRt5y"
}
]
}
]

View File

@@ -1 +0,0 @@
{"id":"resp_017c5eb995d99968006a199f9b483481979ca790db905506fc","created_at":1780064155.0,"error":null,"incomplete_details":null,"instructions":"You solve problems step by step and give a final answer.","metadata":{},"model":"o3-mini-2025-01-31","object":"response","output":[{"id":"rs_017c5eb995d99968006a199f9bff248197978bf9193f34c29a","summary":[],"type":"reasoning"},{"id":"msg_017c5eb995d99968006a199f9c8dcc81979f4e10afbe5350a1","content":[{"annotations":[],"text":"Step 1: Let the cost of the ball be x dollars.\nStep 2: Since the bat costs $1 more than the ball, its cost is x + 1 dollars.\nStep 3: According to the problem, the total cost of the bat and ball is $1.10. Set up the equation:\n  x + (x + 1) = 1.10\nStep 4: Simplify the equation:\n  2x + 1 = 1.10\nStep 5: Subtract 1 from both sides:\n  2x = 0.10\nStep 6: Divide both sides by 2:\n  x = 0.05\n\nFinal Answer: The ball costs $0.05.","type":"output_text","logprobs":[]}],"role":"assistant","status":"completed","type":"message"}],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","tools":[],"top_p":1.0,"background":false,"completed_at":1780064157.0,"max_output_tokens":null,"max_tool_calls":null,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":"medium","summary":null,"context":"current_turn"},"safety_identifier":null,"service_tier":"default","status":"completed","text":{"format":{"type":"text"},"verbosity":"medium"},"top_logprobs":0,"truncation":"disabled","usage":{"input_tokens":49,"input_tokens_details":{"cached_tokens":0},"output_tokens":431,"output_tokens_details":{"reasoning_tokens":128},"total_tokens":480},"user":null,"billing":{"payer":"developer"},"frequency_penalty":0.0,"moderation":null,"presence_penalty":0.0,"store":true}

View File

@@ -1,20 +0,0 @@
[
{
"role": "assistant",
"content": [
{
"type": "thinking"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"content": "Step 1: Let the cost of the ball be x dollars.\nStep 2: Since the bat costs $1 more than the ball, its cost is x + 1 dollars.\nStep 3: According to the problem, the total cost of the bat and ball is $1.10. Set up the equation:\n  x + (x + 1) = 1.10\nStep 4: Simplify the equation:\n  2x + 1 = 1.10\nStep 5: Subtract 1 from both sides:\n  2x = 0.10\nStep 6: Divide both sides by 2:\n  x = 0.05\n\nFinal Answer: The ball costs $0.05."
}
],
"finishReason": "stop"
}
]

View File

@@ -1 +0,0 @@
{"messages": [{"role": "user", "content": "What's the weather in Paris and in London? Use the tool for each."}, {"content": null, "refusal": null, "role": "assistant", "annotations": [], "audio": null, "function_call": null, "tool_calls": [{"id": "call_SW4hst7nzysgvWSAz4OdyK9D", "function": {"arguments": "{\"city\": \"Paris\"}", "name": "get_current_weather"}, "type": "function"}, {"id": "call_si8wiETYGpooMB7gMXSgMQpk", "function": {"arguments": "{\"city\": \"London\"}", "name": "get_current_weather"}, "type": "function"}]}, {"role": "tool", "tool_call_id": "call_SW4hst7nzysgvWSAz4OdyK9D", "content": "{\"city\": \"Paris\", \"temp_c\": 18, \"summary\": \"Clear\"}"}, {"role": "tool", "tool_call_id": "call_si8wiETYGpooMB7gMXSgMQpk", "content": "{\"city\": \"London\", \"temp_c\": 18, \"summary\": \"Clear\"}"}], "model": "gpt-4o-mini", "max_tokens": 80}

View File

@@ -1,52 +0,0 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Paris and in London? Use the tool for each."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"name": "get_current_weather",
"arguments": {
"city": "Paris"
}
},
{
"type": "tool_call",
"id": "call_si8wiETYGpooMB7gMXSgMQpk",
"name": "get_current_weather",
"arguments": {
"city": "London"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Paris\", \"temp_c\": 18, \"summary\": \"Clear\"}",
"toolCallId": "call_SW4hst7nzysgvWSAz4OdyK9D"
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"London\", \"temp_c\": 18, \"summary\": \"Clear\"}",
"toolCallId": "call_si8wiETYGpooMB7gMXSgMQpk"
}
]
}
]

View File

@@ -1 +0,0 @@
{"id":"chatcmpl-DksB6cYbMPwTBkJixOAuxKGZpQKds","choices":[{"finish_reason":"tool_calls","index":0,"logprobs":null,"message":{"content":null,"refusal":null,"role":"assistant","annotations":[],"tool_calls":[{"id":"call_MdYKTRrkshuBG9W5oBXYZaRc","function":{"arguments":"{\"city\":\"Bengaluru\",\"unit\":\"c\"}","name":"get_current_weather"},"type":"function"}]}}],"created":1780063720,"model":"gpt-4o-mini-2024-07-18","object":"chat.completion","service_tier":"default","system_fingerprint":"fp_c6aa0586c9","usage":{"completion_tokens":22,"prompt_tokens":65,"total_tokens":87,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}}}

View File

@@ -1,17 +0,0 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_MdYKTRrkshuBG9W5oBXYZaRc",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru",
"unit": "c"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -1 +0,0 @@
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_TaqNOsbmsz4ifWLVyDHYAAEK", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_TaqNOsbmsz4ifWLVyDHYAAEK", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]

View File

@@ -1,34 +0,0 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_TaqNOsbmsz4ifWLVyDHYAAEK",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
"toolCallId": "call_TaqNOsbmsz4ifWLVyDHYAAEK"
}
]
}
]

View File

@@ -1 +0,0 @@
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_TaqNOsbmsz4ifWLVyDHYAAEK", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]

Some files were not shown because too many files have changed in this diff Show More