Compare commits

..

8 Commits

Author SHA1 Message Date
Gaurav Tewari
91ce47351a feat: intial draft for trace details 2026-10-07 16:47:35 +05:30
Gaurav Tewari
56cef582e4 refactor: trace details and and header changes 2026-10-07 16:47:35 +05:30
Gaurav Tewari
67b9f42e7a feat: trace detail header & tooltip changes 2026-10-07 16:47:35 +05:30
Gaurav Tewari
8537a7688c feat: update badge color 2026-10-07 16:47:34 +05:30
Gaurav Tewari
6e8e6f3659 feat: add support for badge and update ai icons 2026-10-07 16:47:34 +05:30
Gaurav Tewari
b317048220 chore: update tooltip design and support ai spans 2026-10-07 16:47:34 +05:30
Gaurav Tewari
5c9a8eb19f chore: another refactor approch 2026-10-07 11:17:33 +00:00
Gaurav Tewari
a54206ed5e feat: add trace tab and initial refactor 2026-10-07 11:17:33 +00:00
169 changed files with 4437 additions and 5009 deletions

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:
@@ -9917,19 +9878,6 @@ components:
- totalErrorSpansCount
- hasMissingSpans
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:
@@ -10244,69 +10192,6 @@ components:
nullable: true
type: object
type: object
SpantypesThreadSpan:
properties:
attributes:
additionalProperties: {}
type: object
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
formatted_input:
items:
$ref: '#/components/schemas/AiobservabilitytypesMessage'
type: array
formatted_output:
items:
$ref: '#/components/schemas/AiobservabilitytypesMessage'
type: array
has_error:
type: boolean
kind_string:
type: string
name:
type: string
parent_span_id:
type: string
references:
items:
$ref: '#/components/schemas/SpantypesOtelSpanRef'
type: array
resource:
additionalProperties:
type: string
type: object
span_id:
type: string
status_code_string:
type: string
status_message:
type: string
time_unix:
minimum: 0
type: integer
trace_id:
type: string
required:
- span_id
- trace_id
- parent_span_id
- name
- kind_string
- time_unix
- duration_nano
- has_error
- status_code_string
- status_message
- resource
- attributes
- events
- references
type: object
SpantypesTraceAISummary:
properties:
tokens:
@@ -16124,104 +16009,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:
- description: Page size, at most 100. 0 means 20.
in: query
name: limit
schema:
description: Page size, at most 100. 0 means 20.
type: integer
- description: The nextCursor of a page; returns the spans after it. Set only
one of after, before and spanId.
in: query
name: after
schema:
description: The nextCursor of a page; returns the spans after it. Set only
one of after, before and spanId.
type: string
- description: The prevCursor of a page; returns the spans before it. Set only
one of after, before and spanId.
in: query
name: before
schema:
description: The prevCursor of a page; returns the spans before it. Set
only one of after, before and spanId.
type: string
- description: Returns the page around this span. Set only one of after, before
and spanId.
in: query
name: spanId
schema:
description: Returns the page around this span. Set only one of after, before
and spanId.
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
@@ -11473,94 +11426,6 @@ export interface SpantypesOtelSpanRefDTO {
traceId?: string;
}
export type SpantypesThreadSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesThreadSpanDTOResource = { [key: string]: string };
export interface SpantypesThreadSpanDTO {
/**
* @type object
*/
attributes: SpantypesThreadSpanDTOAttributes;
/**
* @type integer
* @minimum 0
*/
duration_nano: number;
/**
* @type array
*/
events: SpantypesEventDTO[];
/**
* @type array
*/
formatted_input?: AiobservabilitytypesMessageDTO[];
/**
* @type array
*/
formatted_output?: AiobservabilitytypesMessageDTO[];
/**
* @type boolean
*/
has_error: boolean;
/**
* @type string
*/
kind_string: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
parent_span_id: string;
/**
* @type array
*/
references: SpantypesOtelSpanRefDTO[];
/**
* @type object
*/
resource: SpantypesThreadSpanDTOResource;
/**
* @type string
*/
span_id: string;
/**
* @type string
*/
status_code_string: string;
/**
* @type string
*/
status_message: string;
/**
* @type integer
* @minimum 0
*/
time_unix: number;
/**
* @type string
*/
trace_id: string;
}
export interface SpantypesGettableTraceThreadDTO {
/**
* @type string
*/
nextCursor?: string;
/**
* @type string
*/
prevCursor?: string;
/**
* @type array
*/
spans: SpantypesThreadSpanDTO[];
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
@@ -13245,40 +13110,6 @@ export type GetTraceSummary200 = {
status: string;
};
export type GetTraceThreadPathParameters = {
traceID: string;
};
export type GetTraceThreadParams = {
/**
* @type integer
* @description Page size, at most 100. 0 means 20.
*/
limit?: number;
/**
* @type string
* @description The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId.
*/
after?: string;
/**
* @type string
* @description The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId.
*/
before?: string;
/**
* @type string
* @description Returns the page around this span. Set only one of after, before and spanId.
*/
spanId?: string;
};
export type GetTraceThread200 = {
data: SpantypesGettableTraceThreadDTO;
/**
* @type string
*/
status: string;
};
export type ListUserPreferences200 = {
/**
* @type array

View File

@@ -24,9 +24,6 @@ import type {
GetTraceAggregationsPathParameters,
GetTraceSummary200,
GetTraceSummaryPathParameters,
GetTraceThread200,
GetTraceThreadParams,
GetTraceThreadPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -260,121 +257,6 @@ export const invalidateGetTraceSummary = async (
return queryClient;
};
/**
* 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

@@ -32,6 +32,7 @@ export const REACT_QUERY_KEY = {
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_TRACE_V3_FLAMEGRAPH: 'GET_TRACE_V3_FLAMEGRAPH',
GET_TRACE_THREAD: 'GET_TRACE_THREAD',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',
GET_DEPLOYMENT_LIST: 'GET_DEPLOYMENT_LIST',

View File

@@ -0,0 +1,247 @@
.message {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.roleHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.parts {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.empty {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.textBox {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-5);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
min-width: 0;
}
.text {
font-size: var(--periscope-font-size-base);
line-height: var(--line-height-20);
color: var(--l1-foreground);
word-break: break-word;
:global(p) {
margin: 0 0 var(--spacing-4);
}
:global(p:last-child) {
margin-bottom: 0;
}
:global(ol),
:global(ul) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-10);
}
}
.plainText {
white-space: pre-wrap;
}
.showMore {
align-self: flex-start;
padding: 0;
border: none;
background: none;
font-size: var(--font-size-xs);
color: var(--primary);
cursor: pointer;
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
align-self: flex-start;
max-width: 100%;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-2);
background: var(--l2-background);
font-size: var(--font-size-xs);
color: var(--l1-foreground);
min-width: 0;
}
.dot {
flex-shrink: 0;
width: var(--spacing-3);
height: var(--spacing-3);
border-radius: var(--radius-round);
&[data-tone='success'] {
background: var(--accent-forest);
}
&[data-tone='warning'] {
background: var(--accent-amber);
}
&[data-tone='error'] {
background: var(--accent-cherry);
}
}
.chipLabel {
flex-shrink: 0;
}
.chipSeparator {
flex-shrink: 0;
color: var(--l2-foreground);
}
.chipValue {
display: inline-flex;
gap: var(--spacing-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--accent-primary);
}
.chipKey {
flex-shrink: 0;
}
.toolName {
overflow: hidden;
text-overflow: ellipsis;
color: var(--accent-amber);
}
.toolCall,
.toolResult {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
min-width: 0;
}
.toolCall {
border-radius: var(--radius-2);
transition: outline-color 0.3s ease;
outline: 1px solid transparent;
outline-offset: var(--spacing-1);
&.isHighlighted {
outline-color: var(--primary);
}
}
.toolLink {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 0;
border: none;
background: none;
color: var(--l2-foreground);
cursor: pointer;
&:hover:not(:disabled) {
color: var(--primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.collapsible {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
&.boxed {
gap: 0;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
.collapsibleToggle {
justify-content: space-between;
padding: var(--spacing-3) var(--spacing-5);
}
.collapsibleBody {
padding: 0 var(--spacing-5) var(--spacing-4);
}
}
}
.collapsibleToggle {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
padding: 0;
border: none;
background: none;
font-size: var(--font-size-xs);
color: var(--l2-foreground);
cursor: pointer;
text-align: left;
}
.collapsibleLabel {
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.collapsibleBody {
display: flex;
flex-direction: column;
min-width: 0;
}
.json {
min-width: 0;
padding: var(--spacing-3) var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
cursor: auto;
:global(.pretty-view) {
padding: 0;
}
}
.mediaChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
align-self: flex-start;
padding: var(--spacing-2) var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-round);
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,89 @@
import { Badge } from '@signozhq/ui/badge';
import { ThreadMessage } from '../TraceDetailsThread/types';
import CollapsibleBlock from './CollapsibleBlock';
import MessagePart from './MessagePart';
import PartChip from './PartChip';
import { getFinishReasonTone, getRoleLabel } from './utils';
import styles from './AIThreadMessage.module.scss';
export interface AIThreadMessageProps {
message: ThreadMessage;
isOutput?: boolean;
isToolLinkEnabled?: (toolCallId: string) => boolean;
onToolLinkClick?: (toolCallId: string) => void;
}
function AIThreadMessage({
message,
isOutput,
isToolLinkEnabled,
onToolLinkClick,
}: AIThreadMessageProps): JSX.Element {
const { role, content, finishReason } = message;
const roleLabel = getRoleLabel(role);
const tone = getFinishReasonTone(finishReason);
const parts = (
<div className={styles.parts}>
{content.length === 0 ? (
<span className={styles.empty}>No content</span>
) : (
content.map((part, index) => (
<MessagePart
// Parts have no stable id and never reorder.
// eslint-disable-next-line react/no-array-index-key
key={index}
part={part}
isToolLinkEnabled={isToolLinkEnabled}
onToolLinkClick={onToolLinkClick}
/>
))
)}
</div>
);
return (
<div
className={styles.message}
data-testid={`ai-thread-message-${role || 'unknown'}`}
>
{role === 'system' ? (
<CollapsibleBlock label={roleLabel} testId="ai-message-system-toggle">
{parts}
</CollapsibleBlock>
) : (
<>
{roleLabel && (
<div className={styles.roleHeader}>
{roleLabel}
{isOutput && (
<Badge color={tone} testId="ai-message-output-badge">
Output
</Badge>
)}
</div>
)}
{parts}
</>
)}
{finishReason && (
<PartChip
label="Finish Reason"
value={finishReason}
tone={tone}
testId="ai-message-finish-reason"
/>
)}
</div>
);
}
AIThreadMessage.defaultProps = {
isOutput: false,
isToolLinkEnabled: undefined,
onToolLinkClick: undefined,
};
export default AIThreadMessage;

View File

@@ -0,0 +1,50 @@
import { MouseEvent, ReactNode, useState } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import cx from 'classnames';
import styles from './AIThreadMessage.module.scss';
interface CollapsibleBlockProps {
label: ReactNode;
// Boxed: full-width bordered row with the chevron on the right.
isBoxed?: boolean;
testId?: string;
children: ReactNode;
}
function CollapsibleBlock({
label,
isBoxed = false,
testId,
children,
}: CollapsibleBlockProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const handleToggle = (event: MouseEvent): void => {
event.stopPropagation();
setIsOpen((prev) => !prev);
};
return (
<div className={cx(styles.collapsible, isBoxed && styles.boxed)}>
<button
type="button"
className={styles.collapsibleToggle}
onClick={handleToggle}
aria-expanded={isOpen}
data-testid={testId}
>
<span className={styles.collapsibleLabel}>{label}</span>
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{isOpen && <div className={styles.collapsibleBody}>{children}</div>}
</div>
);
}
CollapsibleBlock.defaultProps = {
isBoxed: false,
testId: undefined,
};
export default CollapsibleBlock;

View File

@@ -0,0 +1,56 @@
import { MouseEvent, useState } from 'react';
import cx from 'classnames';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { MAX_TEXT_LENGTH } from './utils';
import styles from './AIThreadMessage.module.scss';
interface ExpandableTextProps {
text: string;
isMarkdown?: boolean;
}
function ExpandableText({
text,
isMarkdown = false,
}: ExpandableTextProps): JSX.Element {
const [isExpanded, setIsExpanded] = useState(false);
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
const handleToggle = (event: MouseEvent): void => {
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
return (
<>
{isMarkdown ? (
<MarkdownRenderer
className={styles.text}
markdownContent={visibleText}
variables={{}}
/>
) : (
<div className={cx(styles.text, styles.plainText)}>{visibleText}</div>
)}
{text.length > MAX_TEXT_LENGTH && (
<button
type="button"
className={styles.showMore}
onClick={handleToggle}
data-testid="ai-message-show-more"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</>
);
}
ExpandableText.defaultProps = {
isMarkdown: false,
};
export default ExpandableText;

View File

@@ -0,0 +1,26 @@
import { PrettyView } from 'periscope/components/PrettyView';
import styles from './AIThreadMessage.module.scss';
interface JsonBlockProps {
data: object;
}
// Expanding tree nodes must not count as a click on the message.
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={styles.json}
onClick={(event): void => event.stopPropagation()}
>
<PrettyView
data={data as Record<string, unknown>}
searchable={false}
drawerKey="ai-thread-message"
/>
</div>
);
}
export default JsonBlock;

View File

@@ -0,0 +1,64 @@
import { MessagePart as MessagePartType } from '../TraceDetailsThread/types';
import MediaPart from './parts/MediaPart';
import ThinkingPart from './parts/ThinkingPart';
import ToolCallPart from './parts/ToolCallPart';
import ToolResultPart from './parts/ToolResultPart';
import TextContent from './TextContent';
interface MessagePartProps {
part: MessagePartType;
isToolLinkEnabled?: (toolCallId: string) => boolean;
onToolLinkClick?: (toolCallId: string) => void;
}
function MessagePart({
part,
isToolLinkEnabled,
onToolLinkClick,
}: MessagePartProps): JSX.Element | null {
switch (part.type) {
case 'text':
return <TextContent text={part.content} isMarkdown />;
case 'thinking':
return <ThinkingPart content={part.content} />;
case 'tool_call':
return (
<ToolCallPart
id={part.id}
name={part.name}
args={part.arguments}
server={part.server}
/>
);
case 'tool_result':
return (
<ToolResultPart
toolCallId={part.toolCallId}
name={part.name}
content={part.content}
isError={part.isError}
isLinkEnabled={!!isToolLinkEnabled?.(part.toolCallId)}
onLinkClick={onToolLinkClick}
/>
);
case 'media':
return (
<MediaPart
modality={part.modality}
mimeType={part.mimeType}
name={part.name}
/>
);
case 'generic':
return <TextContent text={part.content} />;
default:
return null;
}
}
MessagePart.defaultProps = {
isToolLinkEnabled: undefined,
onToolLinkClick: undefined,
};
export default MessagePart;

View File

@@ -0,0 +1,44 @@
import { ReactNode } from 'react';
import { MessageTone } from './utils';
import styles from './AIThreadMessage.module.scss';
interface PartChipProps {
label: string;
value?: ReactNode;
tone?: MessageTone;
testId?: string;
children?: ReactNode;
}
function PartChip({
label,
value,
tone = 'success',
testId,
children,
}: PartChipProps): JSX.Element {
return (
<span className={styles.chip} data-testid={testId}>
<span className={styles.dot} data-tone={tone} />
<span className={styles.chipLabel}>{label}</span>
{value !== undefined && (
<>
<span className={styles.chipSeparator}>—</span>
<span className={styles.chipValue}>{value}</span>
</>
)}
{children}
</span>
);
}
PartChip.defaultProps = {
value: undefined,
tone: 'success',
testId: undefined,
children: undefined,
};
export default PartChip;

View File

@@ -0,0 +1,35 @@
import { useMemo } from 'react';
import ExpandableText from './ExpandableText';
import JsonBlock from './JsonBlock';
import { parseJsonObject } from './utils';
import styles from './AIThreadMessage.module.scss';
interface TextContentProps {
text: string;
isMarkdown?: boolean;
}
/** Renders JSON text as a tree, anything else as (markdown) text. */
function TextContent({
text,
isMarkdown = false,
}: TextContentProps): JSX.Element {
const json = useMemo(() => parseJsonObject(text), [text]);
if (json) {
return <JsonBlock data={json} />;
}
return (
<div className={styles.textBox}>
<ExpandableText text={text} isMarkdown={isMarkdown} />
</div>
);
}
TextContent.defaultProps = {
isMarkdown: false,
};
export default TextContent;

View File

@@ -0,0 +1,43 @@
import { RefObject, useCallback } from 'react';
import { TOOL_CALL_ID_ATTR } from './utils';
import styles from './AIThreadMessage.module.scss';
const HIGHLIGHT_MS = 1500;
interface ToolCallLinks {
isToolLinkEnabled: (toolCallId: string) => boolean;
onToolLinkClick: (toolCallId: string) => void;
}
/** Links tool results to their calls in `rootRef`; enabled only for ids in `toolCallIds`. */
export function useToolCallLinks(
rootRef: RefObject<HTMLElement>,
toolCallIds: Set<string>,
): ToolCallLinks {
const isToolLinkEnabled = useCallback(
(toolCallId: string): boolean => toolCallIds.has(toolCallId),
[toolCallIds],
);
const onToolLinkClick = useCallback(
(toolCallId: string): void => {
const target = rootRef.current?.querySelector<HTMLElement>(
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallId)}"]`,
);
if (!target) {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add(styles.isHighlighted);
window.setTimeout(
() => target.classList.remove(styles.isHighlighted),
HIGHLIGHT_MS,
);
},
[rootRef],
);
return { isToolLinkEnabled, onToolLinkClick };
}

View File

@@ -0,0 +1,53 @@
import { ThreadMessage } from '../TraceDetailsThread/types';
export const MAX_TEXT_LENGTH = 1000;
export const MAX_INLINE_RESULT_LENGTH = 80;
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
const ROLE_LABELS: Record<string, string> = {
system: 'System',
user: 'User',
assistant: 'Assistant',
tool: 'Tool',
};
export type MessageTone = 'success' | 'warning' | 'error';
const FINISH_REASON_TONES: Record<string, MessageTone> = {
length: 'warning',
content_filter: 'warning',
error: 'error',
};
/** Undefined for missing or unknown roles, which render headerless. */
export function getRoleLabel(role?: string): string | undefined {
return role ? ROLE_LABELS[role] : undefined;
}
export function getFinishReasonTone(reason?: string): MessageTone {
return (reason && FINISH_REASON_TONES[reason]) || 'success';
}
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
export function parseJsonObject(text: string): object | undefined {
const trimmed = text.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return undefined;
}
try {
const parsed: unknown = JSON.parse(trimmed);
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
} catch {
return undefined;
}
}
export function collectToolCallIds(messages: ThreadMessage[]): string[] {
return messages.flatMap((message) =>
message.content.flatMap((part) =>
part.type === 'tool_call' ? [part.id] : [],
),
);
}

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,25 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-4) 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: var(--letter-spacing-wide);
}
.scroll {
max-height: 480px;
overflow-y: auto;
padding: var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}

View File

@@ -0,0 +1,62 @@
import { useMemo, useRef, useState } from 'react';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import SpanMessages from '../../SpanMessages/SpanMessages';
import {
getSpanMessages,
hasAnyMessages,
hasFormattedMessages,
} from '../../SpanMessages/utils';
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import styles from './SpanAIMessages.module.scss';
interface SpanAIMessagesProps {
span: ThreadSpan;
}
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
const data = useMemo(() => getSpanMessages(span), [span]);
const canFormat = hasFormattedMessages(data);
const [view, setView] = useState(ThreadView.Formatted);
const effectiveView = canFormat ? view : ThreadView.Json;
const scrollRef = useRef<HTMLDivElement>(null);
const toolCallIds = useMemo(() => buildToolCallIndex([span]), [span]);
const { isToolLinkEnabled, onToolLinkClick } = useToolCallLinks(
scrollRef,
toolCallIds,
);
if (!hasAnyMessages(data)) {
return null;
}
return (
<div className={styles.root} data-testid="span-ai-messages">
<div className={styles.header}>
Messages
{canFormat && (
<ThreadViewToggle
value={effectiveView}
onChange={setView}
testId="span-ai-messages-view-toggle"
/>
)}
</div>
<div className={styles.scroll} ref={scrollRef}>
<SpanMessages
span={span}
view={effectiveView}
isToolLinkEnabled={isToolLinkEnabled}
onToolLinkClick={onToolLinkClick}
/>
</div>
</div>
);
}
export default SpanAIMessages;

View File

@@ -52,6 +52,7 @@ import Events from './Events/Events';
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
import SpanLogs from './SpanLogs/SpanLogs';
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
import SpanSummary from './SpanSummary';
import styles from './SpanDetailsPanel.module.scss';
@@ -320,6 +321,7 @@ function SpanDetailsContent({
<div className={styles.tabsScroll}>
<TabsContent value="overview">
{isWide && summary}
<SpanAIMessages span={selectedSpan} />
<DataViewer
data={spanDisplayData}
drawerKey="trace-details"

View File

@@ -1,9 +1,11 @@
import { useMemo } from 'react';
import { Link2 } from '@signozhq/icons';
import dayjs from 'dayjs';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import { getSpanAiDetails, getSpanTokenUsage } from '../SpanHoverCard/aiUsage';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
LinkedSpansPanel,
@@ -31,6 +33,11 @@ function SpanSummary({
}: SpanSummaryProps): JSX.Element {
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
const tokens = useMemo(() => getSpanTokenUsage(selectedSpan), [selectedSpan]);
const cost = useMemo(
() => getSpanAiDetails(selectedSpan)?.usage?.cost,
[selectedSpan],
);
return (
<>
@@ -63,6 +70,8 @@ function SpanSummary({
: undefined
}
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
tokens={tokens}
cost={cost}
/>
<div className={styles.spanInfoItem}>

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,112 @@
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
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 })}`;
}
export function getSpanTokenUsage(span: SpanV3): TraceTokenUsage | undefined {
const usage = getSpanAiDetails(span)?.usage;
if (!usage) {
return undefined;
}
return {
input: usage.inputTokens ?? 0,
output: usage.outputTokens ?? 0,
cacheRead: usage.cacheReadTokens,
cacheWrite: usage.cacheCreationTokens,
reasoning: getNumber(span, GEN_AI_KEYS.reasoningTokens),
};
}

View File

@@ -0,0 +1,67 @@
import AIThreadMessage, {
AIThreadMessageProps,
} from '../AIThreadMessage/AIThreadMessage';
import { ThreadMessage, ThreadView } from '../TraceDetailsThread/types';
import RawMessages from './RawMessages';
import { RawMessagesValue } from './utils';
import styles from './SpanMessages.module.scss';
type MessageProps = Pick<
AIThreadMessageProps,
'isOutput' | 'isToolLinkEnabled' | 'onToolLinkClick'
>;
interface MessagesSectionProps extends MessageProps {
title?: string;
view: ThreadView;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesSection({
title,
view,
messages,
raw,
testId,
isOutput,
isToolLinkEnabled,
onToolLinkClick,
}: MessagesSectionProps): JSX.Element | null {
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? messages.length === 0 : !raw) {
return null;
}
return (
<section className={styles.section} data-testid={testId}>
{title && <div className={styles.sectionTitle}>{title}</div>}
{isFormatted ? (
<div className={styles.messages}>
{messages.map((message, index) => (
<AIThreadMessage
// Messages have no stable id and never reorder.
// eslint-disable-next-line react/no-array-index-key
key={index}
message={message}
isOutput={isOutput}
isToolLinkEnabled={isToolLinkEnabled}
onToolLinkClick={onToolLinkClick}
/>
))}
</div>
) : (
<RawMessages value={raw as RawMessagesValue} />
)}
</section>
);
}
MessagesSection.defaultProps = {
title: undefined,
raw: undefined,
};
export default MessagesSection;

View File

@@ -0,0 +1,21 @@
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { RawMessagesValue } from './utils';
import styles from './SpanMessages.module.scss';
interface RawMessagesProps {
value: RawMessagesValue;
}
function RawMessages({ value }: RawMessagesProps): JSX.Element {
if (typeof value === 'object') {
return <JsonBlock data={value} />;
}
return (
<pre className={styles.raw} data-testid="span-raw-messages">
{value}
</pre>
);
}
export default RawMessages;

View File

@@ -0,0 +1,49 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
.sectionTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
}
.messages {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
min-width: 0;
}
.raw {
margin: 0;
padding: var(--spacing-4) var(--spacing-6);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
font-family: var(--font-family-sf-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-18);
color: var(--l1-foreground);
white-space: pre-wrap;
word-break: break-all;
}
.missing {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,67 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { AIThreadMessageProps } from '../AIThreadMessage/AIThreadMessage';
import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
import MessagesSection from './MessagesSection';
import { getSpanMessages, hasAnyMessages } from './utils';
import styles from './SpanMessages.module.scss';
interface SpanMessagesProps extends Pick<
AIThreadMessageProps,
'isToolLinkEnabled' | 'onToolLinkClick'
> {
span: ThreadSpan;
view: ThreadView;
className?: string;
}
/** Input and output messages of one AI span, shared by Thread and Span Details. */
function SpanMessages({
span,
view,
className,
isToolLinkEnabled,
onToolLinkClick,
}: SpanMessagesProps): JSX.Element {
const data = useMemo(() => getSpanMessages(span), [span]);
const isFormatted = view === ThreadView.Formatted;
const callbacks = { isToolLinkEnabled, onToolLinkClick };
if (!hasAnyMessages(data)) {
return (
<div className={styles.missing}>
This span has no input or output messages.
</div>
);
}
return (
<div className={cx(styles.root, className)}>
<MessagesSection
title={isFormatted ? undefined : 'Input'}
view={view}
messages={data.formattedInput}
raw={data.rawInput}
testId="span-messages-input"
{...callbacks}
/>
<MessagesSection
title={isFormatted ? undefined : 'Output'}
view={view}
messages={data.formattedOutput}
raw={data.rawOutput}
isOutput
testId="span-messages-output"
{...callbacks}
/>
</div>
);
}
SpanMessages.defaultProps = {
className: undefined,
};
export default SpanMessages;

View File

@@ -0,0 +1,40 @@
import { parseJsonObject } from '../AIThreadMessage/utils';
import { ThreadMessage, ThreadSpan } from '../TraceDetailsThread/types';
import { GEN_AI_KEYS } from '../utils/genAi';
/** Parsed JSON, or the original string when it isn't JSON. */
export type RawMessagesValue = object | string;
export interface SpanMessagesData {
formattedInput: ThreadMessage[];
formattedOutput: ThreadMessage[];
rawInput?: RawMessagesValue;
rawOutput?: RawMessagesValue;
}
function toRawValue(value: unknown): RawMessagesValue | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value === 'string') {
return parseJsonObject(value) ?? value;
}
return typeof value === 'object' ? value : JSON.stringify(value);
}
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
return {
formattedInput: span.formatted_input ?? [],
formattedOutput: span.formatted_output ?? [],
rawInput: toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]),
rawOutput: toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]),
};
}
export function hasFormattedMessages(data: SpanMessagesData): boolean {
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
}
export function hasAnyMessages(data: SpanMessagesData): boolean {
return hasFormattedMessages(data) || !!data.rawInput || !!data.rawOutput;
}

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,32 @@
.root {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-4);
padding: var(--spacing-16);
text-align: center;
}
.icon {
color: var(--l2-foreground);
}
.title {
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.description {
max-width: 420px;
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.actions {
display: flex;
gap: var(--spacing-4);
margin-top: var(--spacing-4);
}

View File

@@ -0,0 +1,34 @@
import { ReactNode } from 'react';
import styles from './ThreadEmptyState.module.scss';
interface ThreadEmptyStateProps {
icon: ReactNode;
title: string;
description: string;
actions?: ReactNode;
testId: string;
}
function ThreadEmptyState({
icon,
title,
description,
actions,
testId,
}: ThreadEmptyStateProps): JSX.Element {
return (
<div className={styles.root} data-testid={testId}>
<span className={styles.icon}>{icon}</span>
<span className={styles.title}>{title}</span>
<span className={styles.description}>{description}</span>
{actions && <div className={styles.actions}>{actions}</div>}
</div>
);
}
ThreadEmptyState.defaultProps = {
actions: undefined,
};
export default ThreadEmptyState;

View File

@@ -0,0 +1,17 @@
import { Skeleton } from '@signozhq/ui/skeleton';
import styles from './ThreadSpanList.module.scss';
interface ThreadPageLoaderProps {
testId: string;
}
function ThreadPageLoader({ testId }: ThreadPageLoaderProps): JSX.Element {
return (
<div className={styles.pageLoader} data-testid={testId}>
<Skeleton active title={{ width: '30%' }} paragraph={{ rows: 2 }} />
</div>
);
}
export default ThreadPageLoader;

View File

@@ -0,0 +1,16 @@
.card {
padding: var(--spacing-8);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
min-width: 0;
cursor: pointer;
&:hover {
border-color: var(--l3-border);
}
&.isSelected {
border-color: var(--primary);
}
}

View File

@@ -0,0 +1,44 @@
import { memo } from 'react';
import cx from 'classnames';
import SpanMessages from '../SpanMessages/SpanMessages';
import { ThreadSpan, ThreadView } from './types';
import styles from './ThreadSpanCard.module.scss';
interface ThreadSpanCardProps {
span: ThreadSpan;
view: ThreadView;
isSelected: boolean;
onSelect: (span: ThreadSpan) => void;
isToolLinkEnabled: (toolCallId: string) => boolean;
onToolLinkClick: (toolCallId: string) => void;
}
function ThreadSpanCard({
span,
view,
isSelected,
onSelect,
isToolLinkEnabled,
onToolLinkClick,
}: ThreadSpanCardProps): JSX.Element {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<article
className={cx(styles.card, isSelected && styles.isSelected)}
onClick={(): void => onSelect(span)}
data-span-id={span.span_id}
data-testid="thread-span-card"
>
<SpanMessages
span={span}
view={view}
isToolLinkEnabled={isToolLinkEnabled}
onToolLinkClick={onToolLinkClick}
/>
</article>
);
}
export default memo(ThreadSpanCard);

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { ResizableBox } from 'periscope/components/ResizableBox';
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { useTraceSummary } from '../TraceDetailsHeader/useTraceSummary';
import { ThreadSpan } from './types';
import styles from './TraceDetailsThread.module.scss';
const RIGHT_DOCK_MIN = 480;
const RIGHT_DOCK_MAX = 720;
interface ThreadSpanDetailsProps {
panelState: DetailsPanelState;
span: ThreadSpan;
}
function ThreadSpanDetails({
panelState,
span,
}: ThreadSpanDetailsProps): JSX.Element {
const { data: summary } = useTraceSummary();
const [width, setWidth] = useState(RIGHT_DOCK_MIN);
return (
<ResizableBox
handle="left"
defaultWidth={width}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={span}
variant={SpanDetailVariant.DOCKED_RIGHT}
traceStartTime={summary?.startTimestampMillis}
traceEndTime={summary?.endTimestampMillis}
/>
</ResizableBox>
);
}
export default ThreadSpanDetails;

View File

@@ -0,0 +1,23 @@
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6) var(--spacing-8);
// Prepend compensation is manual; native anchoring would double-shift.
overflow-anchor: none;
}
.sentinel {
flex-shrink: 0;
height: 1px;
}
.pageLoader {
flex-shrink: 0;
padding: var(--spacing-4) var(--spacing-6);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}

View File

@@ -0,0 +1,55 @@
import { ReactNode, RefObject } from 'react';
import { useThreadInfiniteScroll } from './hooks/useThreadInfiniteScroll';
import ThreadPageLoader from './ThreadPageLoader';
import styles from './ThreadSpanList.module.scss';
interface ThreadSpanListProps {
listRef: RefObject<HTMLDivElement>;
firstItemKey?: string;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
children: ReactNode;
}
function ThreadSpanList({
listRef,
firstItemKey,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
children,
}: ThreadSpanListProps): JSX.Element {
const { topSentinelRef, bottomSentinelRef } = useThreadInfiniteScroll({
containerRef: listRef,
firstItemKey,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
});
return (
<div className={styles.list} ref={listRef} data-testid="thread-span-list">
<div ref={topSentinelRef} className={styles.sentinel} />
{isFetchingPreviousPage && (
<ThreadPageLoader testId="thread-loading-previous" />
)}
{children}
{isFetchingNextPage && <ThreadPageLoader testId="thread-loading-next" />}
<div ref={bottomSentinelRef} className={styles.sentinel} />
</div>
);
}
export default ThreadSpanList;

View File

@@ -0,0 +1,47 @@
import { Button } from '@signozhq/ui/button';
import { ArrowLeft, MessagesSquare, SearchX } from '@signozhq/icons';
import ThreadEmptyState from './ThreadEmptyState';
interface ThreadSpanNotFoundProps {
onGoBack: () => void;
onShowThread: () => void;
}
function ThreadSpanNotFound({
onGoBack,
onShowThread,
}: ThreadSpanNotFoundProps): JSX.Element {
return (
<ThreadEmptyState
icon={<SearchX size={32} />}
title="Span not found in this thread"
description="The requested span isn't part of this trace's AI thread."
testId="thread-span-not-found"
actions={
<>
<Button
variant="outlined"
color="secondary"
prefix={<ArrowLeft size={14} />}
onClick={onGoBack}
testId="thread-span-not-found-go-back"
>
Go back
</Button>
<Button
variant="solid"
color="primary"
prefix={<MessagesSquare size={14} />}
onClick={onShowThread}
testId="thread-span-not-found-show-thread"
>
Show thread
</Button>
</>
}
/>
);
}
export default ThreadSpanNotFound;

View File

@@ -0,0 +1,44 @@
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { ThreadView } from './types';
const VIEW_OPTIONS = [
{ label: 'Formatted', value: ThreadView.Formatted },
{ label: 'JSON', value: ThreadView.Json },
];
interface ThreadViewToggleProps {
value: ThreadView;
onChange: (view: ThreadView) => void;
testId?: string;
}
function ThreadViewToggle({
value,
onChange,
testId,
}: ThreadViewToggleProps): JSX.Element {
const handleChange = (next: string): void => {
// Clicking the active item deselects it and reports ''.
if (next) {
onChange(next as ThreadView);
}
};
return (
<ToggleGroupSimple
type="single"
size="sm"
value={value}
onChange={handleChange}
items={VIEW_OPTIONS}
testId={testId}
/>
);
}
ThreadViewToggle.defaultProps = {
testId: 'thread-view-toggle',
};
export default ThreadViewToggle;

View File

@@ -0,0 +1,44 @@
.root {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
border-top: 1px solid var(--l2-border);
}
.content {
// Leaves room on the right for the span details dock.
flex: 0 1 70%;
min-width: 0;
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l2-border);
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}
.notice {
margin: var(--spacing-6) var(--spacing-8) 0;
}
.initialLoader {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6) var(--spacing-8);
}
.rightDock {
display: flex;
flex-direction: column;
min-width: 0;
border-left: 1px solid var(--l2-border);
}

View File

@@ -0,0 +1,142 @@
import { useMemo, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Callout } from '@signozhq/ui/callout';
import { MessagesSquare } from '@signozhq/icons';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { useToolCallLinks } from '../AIThreadMessage/useToolCallLinks';
import { TraceDetailsTab } from '../constants';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { useScrollToAnchorSpan } from './hooks/useScrollToAnchorSpan';
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
import { useThreadView } from './hooks/useThreadView';
import { useTraceThread } from './hooks/useTraceThread';
import ThreadEmptyState from './ThreadEmptyState';
import ThreadPageLoader from './ThreadPageLoader';
import ThreadSpanCard from './ThreadSpanCard';
import ThreadSpanDetails from './ThreadSpanDetails';
import ThreadSpanList from './ThreadSpanList';
import ThreadSpanNotFound from './ThreadSpanNotFound';
import ThreadViewToggle from './ThreadViewToggle';
import { AnchorStatus } from './types';
import { buildToolCallIndex, getAnchorStatus } from './utils';
import styles from './TraceDetailsThread.module.scss';
const INITIAL_LOADER_COUNT = 3;
function TraceDetailsThread(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const [, setTab] = useTraceDetailsTab();
const [view, setView] = useThreadView();
const { anchorSpanId, clearAnchor, selectedSpanId, selectSpan, panelState } =
useThreadSpanSelection();
const thread = useTraceThread(traceId || '', anchorSpanId);
const { spans, isLoading, isError } = thread;
const listRef = useRef<HTMLDivElement>(null);
const toolCallIds = useMemo(() => buildToolCallIndex(spans), [spans]);
const { isToolLinkEnabled, onToolLinkClick } = useToolCallLinks(
listRef,
toolCallIds,
);
const anchorStatus = getAnchorStatus(anchorSpanId, spans, isLoading);
useScrollToAnchorSpan(
listRef,
anchorSpanId,
anchorStatus === AnchorStatus.Found,
);
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
const renderBody = (): JSX.Element => {
if (isLoading) {
return (
<div className={styles.initialLoader} data-testid="thread-loading">
{Array.from({ length: INITIAL_LOADER_COUNT }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<ThreadPageLoader key={i} testId="thread-loading-item" />
))}
</div>
);
}
if (anchorStatus === AnchorStatus.NotFound) {
return (
<ThreadSpanNotFound
onGoBack={(): void => {
void setTab(TraceDetailsTab.Overview);
}}
onShowThread={clearAnchor}
/>
);
}
if (isError || spans.length === 0) {
return (
<ThreadEmptyState
icon={<MessagesSquare size={32} />}
title="No AI thread for this trace"
description="Threads show the input and output messages of LLM spans. This trace has none."
testId="thread-empty"
/>
);
}
return (
<>
{anchorStatus === AnchorStatus.NoMessages && (
<Callout
type="info"
size="small"
className={styles.notice}
title="The requested span has no input or output messages. Showing the rest of the thread."
testId="thread-anchor-no-messages"
/>
)}
<ThreadSpanList
listRef={listRef}
firstItemKey={spans[0]?.span_id}
hasNextPage={thread.hasNextPage}
hasPreviousPage={thread.hasPreviousPage}
isFetchingNextPage={thread.isFetchingNextPage}
isFetchingPreviousPage={thread.isFetchingPreviousPage}
fetchNextPage={thread.fetchNextPage}
fetchPreviousPage={thread.fetchPreviousPage}
>
{spans.map((span) => (
<ThreadSpanCard
key={span.span_id}
span={span}
view={view}
isSelected={span.span_id === selectedSpanId}
onSelect={selectSpan}
isToolLinkEnabled={isToolLinkEnabled}
onToolLinkClick={onToolLinkClick}
/>
))}
</ThreadSpanList>
</>
);
};
return (
<div className={styles.root} data-testid="trace-details-thread">
<div className={styles.content}>
<div className={styles.toolbar}>
<ThreadViewToggle
value={view}
onChange={(next): void => {
void setView(next);
}}
/>
{spans.length > 0 && <span>Showing {spans.length} spans</span>}
</div>
{renderBody()}
</div>
{panelState.isOpen && selectedSpan && (
<ThreadSpanDetails panelState={panelState} span={selectedSpan} />
)}
</div>
);
}
export default TraceDetailsThread;

View File

@@ -0,0 +1,69 @@
import { MOCK_TRACE_THREAD_SPANS } from './mockThread';
import { ThreadSpan, TraceThreadResponse } from './types';
export interface TraceThreadParams {
traceId: string;
limit: number;
after?: string;
before?: string;
spanId?: string;
}
// Mock-only knobs; the real endpoint pages by `limit`.
const MOCK_PAGE_SIZE = 5;
const MOCK_LATENCY_MS = 400;
const SPANS_BEFORE_ANCHOR = 2;
type WireSpan = Omit<ThreadSpan, 'timestamp' | 'service.name'> & {
time_unix: number;
};
function toThreadSpan(span: WireSpan): ThreadSpan {
return {
...span,
'service.name': span.resource?.['service.name'] || '',
timestamp: span.time_unix,
};
}
function getStartIndex(
spans: WireSpan[],
{ after, before, spanId }: TraceThreadParams,
): number {
if (after) {
return Number(after);
}
if (before) {
return Math.max(0, Number(before) - MOCK_PAGE_SIZE);
}
if (spanId) {
const index = spans.findIndex((s) => s.span_id === spanId);
return index === -1 ? -1 : Math.max(0, index - SPANS_BEFORE_ANCHOR);
}
return 0;
}
/** Placeholder for GET /api/v1/traces/{id}/thread; an unknown `spanId` yields no spans. */
export async function getTraceThread(
params: TraceThreadParams,
): Promise<TraceThreadResponse> {
await new Promise((resolve) => {
setTimeout(resolve, MOCK_LATENCY_MS);
});
const all = MOCK_TRACE_THREAD_SPANS as unknown as WireSpan[];
const start = getStartIndex(all, params);
if (start === -1) {
return { spans: [] };
}
const end = params.before
? Number(params.before)
: Math.min(all.length, start + MOCK_PAGE_SIZE);
return {
spans: all.slice(start, end).map(toThreadSpan),
prevCursor: start > 0 ? String(start) : undefined,
nextCursor: end < all.length ? String(end) : undefined,
};
}

View File

@@ -0,0 +1,28 @@
import { RefObject, useEffect } from 'react';
const ROOT_MARGIN = '200px 0px';
/** Calls `onVisible` while enabled and the sentinel is within reach of the container's viewport. */
export function useIntersectionSentinel(
containerRef: RefObject<HTMLElement>,
sentinelRef: RefObject<HTMLElement>,
isEnabled: boolean,
onVisible: () => void,
): void {
useEffect(() => {
const sentinel = sentinelRef.current;
if (!isEnabled || !sentinel) {
return undefined;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
onVisible();
}
},
{ root: containerRef.current, rootMargin: ROOT_MARGIN },
);
observer.observe(sentinel);
return (): void => observer.disconnect();
}, [containerRef, sentinelRef, isEnabled, onVisible]);
}

View File

@@ -0,0 +1,27 @@
import { RefObject, useEffect, useRef } from 'react';
/** Scrolls the anchor span's card into view once, after it first renders. */
export function useScrollToAnchorSpan(
containerRef: RefObject<HTMLElement>,
anchorSpanId: string | undefined,
isAnchorLoaded: boolean,
): void {
const scrolledForRef = useRef<string>();
useEffect(() => {
if (
!anchorSpanId ||
!isAnchorLoaded ||
scrolledForRef.current === anchorSpanId
) {
return;
}
const card = containerRef.current?.querySelector<HTMLElement>(
`[data-span-id="${CSS.escape(anchorSpanId)}"]`,
);
if (card) {
scrolledForRef.current = anchorSpanId;
card.scrollIntoView({ block: 'start' });
}
}, [containerRef, anchorSpanId, isAnchorLoaded]);
}

View File

@@ -0,0 +1,70 @@
import { RefObject, useLayoutEffect, useRef } from 'react';
import { useIntersectionSentinel } from './useIntersectionSentinel';
interface UseThreadInfiniteScrollOptions {
containerRef: RefObject<HTMLDivElement>;
firstItemKey?: string;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
}
interface UseThreadInfiniteScrollResult {
topSentinelRef: RefObject<HTMLDivElement>;
bottomSentinelRef: RefObject<HTMLDivElement>;
}
/** Loads pages as either list edge nears view, holding position on prepend. */
export function useThreadInfiniteScroll({
containerRef,
firstItemKey,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
}: UseThreadInfiniteScrollOptions): UseThreadInfiniteScrollResult {
const topSentinelRef = useRef<HTMLDivElement>(null);
const bottomSentinelRef = useRef<HTMLDivElement>(null);
const heightBeforePrependRef = useRef<number | null>(null);
const loadPreviousRef = useRef((): void => {});
loadPreviousRef.current = (): void => {
heightBeforePrependRef.current = containerRef.current?.scrollHeight ?? null;
void fetchPreviousPage();
};
// Stable wrapper so the observer isn't recreated on every render.
const stableLoadPrevious = useRef((): void =>
loadPreviousRef.current(),
).current;
useIntersectionSentinel(
containerRef,
bottomSentinelRef,
hasNextPage && !isFetchingNextPage,
fetchNextPage,
);
useIntersectionSentinel(
containerRef,
topSentinelRef,
hasPreviousPage && !isFetchingPreviousPage,
stableLoadPrevious,
);
useLayoutEffect(() => {
const container = containerRef.current;
const previousHeight = heightBeforePrependRef.current;
if (!container || previousHeight === null) {
return;
}
heightBeforePrependRef.current = null;
container.scrollTop += container.scrollHeight - previousHeight;
}, [containerRef, firstItemKey]);
return { topSentinelRef, bottomSentinelRef };
}

View File

@@ -0,0 +1,72 @@
import { useCallback, useMemo, useState } from 'react';
import { useDetailsPanel } from 'components/DetailsPanel';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ThreadSpan } from '../types';
const SPAN_ID_PARAM = 'spanId';
interface ThreadSpanSelection {
// The spanId the thread was opened with; it shapes the first page fetched.
anchorSpanId?: string;
clearAnchor: () => void;
selectedSpanId?: string;
selectSpan: (span: ThreadSpan) => void;
panelState: DetailsPanelState;
}
export function useThreadSpanSelection(): ThreadSpanSelection {
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const selectedSpanId = urlQuery.get(SPAN_ID_PARAM) || undefined;
const [anchorSpanId, setAnchorSpanId] = useState(selectedSpanId);
const setSpanIdParam = useCallback(
(spanId?: string): void => {
if (spanId) {
urlQuery.set(SPAN_ID_PARAM, spanId);
} else {
urlQuery.delete(SPAN_ID_PARAM);
}
safeNavigate({ search: urlQuery.toString() }, { replace: true });
},
[urlQuery, safeNavigate],
);
const handleClose = useCallback(
(): void => setSpanIdParam(undefined),
[setSpanIdParam],
);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleClose,
});
const { open: openPanel } = panelState;
const selectSpan = useCallback(
(span: ThreadSpan): void => {
setSpanIdParam(span.span_id);
openPanel();
},
[setSpanIdParam, openPanel],
);
const clearAnchor = useCallback((): void => {
setAnchorSpanId(undefined);
setSpanIdParam(undefined);
}, [setSpanIdParam]);
return useMemo(
() => ({
anchorSpanId,
clearAnchor,
selectedSpanId,
selectSpan,
panelState,
}),
[anchorSpanId, clearAnchor, selectedSpanId, selectSpan, panelState],
);
}

View File

@@ -0,0 +1,12 @@
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
import { ThreadView } from '../types';
export function useThreadView(): UseQueryStateReturn<ThreadView, ThreadView> {
return useQueryState(
'view',
parseAsStringEnum(Object.values(ThreadView)).withDefault(
ThreadView.Formatted,
),
);
}

View File

@@ -0,0 +1,68 @@
import { useMemo } from 'react';
import { useInfiniteQuery } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getTraceThread } from '../getTraceThread';
import { ThreadSpan, TraceThreadResponse } from '../types';
const THREAD_PAGE_LIMIT = 100;
interface PageParam {
after?: string;
before?: string;
}
interface UseTraceThreadResult {
spans: ThreadSpan[];
isLoading: boolean;
isError: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
}
export function useTraceThread(
traceId: string,
anchorSpanId?: string,
): UseTraceThreadResult {
const query = useInfiniteQuery<TraceThreadResponse>({
queryKey: [REACT_QUERY_KEY.GET_TRACE_THREAD, traceId, anchorSpanId],
queryFn: ({ pageParam }) => {
const { after, before } = (pageParam ?? {}) as PageParam;
return getTraceThread({
traceId,
limit: THREAD_PAGE_LIMIT,
after,
before,
// The anchor only shapes the first page; cursors drive the rest.
spanId: after || before ? undefined : anchorSpanId,
});
},
getNextPageParam: (lastPage): PageParam | undefined =>
lastPage.nextCursor ? { after: lastPage.nextCursor } : undefined,
getPreviousPageParam: (firstPage): PageParam | undefined =>
firstPage.prevCursor ? { before: firstPage.prevCursor } : undefined,
enabled: !!traceId,
refetchOnWindowFocus: false,
});
const spans = useMemo(
() => query.data?.pages.flatMap((page) => page.spans) ?? [],
[query.data],
);
return {
spans,
isLoading: query.isLoading,
isError: query.isError,
hasNextPage: !!query.hasNextPage,
hasPreviousPage: !!query.hasPreviousPage,
isFetchingNextPage: query.isFetchingNextPage,
isFetchingPreviousPage: query.isFetchingPreviousPage,
fetchNextPage: query.fetchNextPage,
fetchPreviousPage: query.fetchPreviousPage,
};
}

View File

@@ -0,0 +1,722 @@
// Placeholder until GET /api/v1/traces/{id}/thread is available. Wire format: spans carry time_unix.
const TRACE_ID = '7a1f0c2d9e8b4a6f5c3d2e1f0a9b8c7d';
const IMAGE_DATA_URL =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAEAAQADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL';
const OBSERVABILITY_PROMPT =
"You are an observability assistant. Observability is understanding a system's internal state from the telemetry it emits: traces, metrics, and logs. For LLM and agent workloads these signals also carry the model used, input and output token counts, the cost those tokens imply, the tools an agent invoked, and whether each call succeeded or failed. Always answer concisely. ".repeat(
18,
);
const SPAN_DEFAULTS = {
events: [],
flags: 0,
has_children: false,
has_error: false,
is_remote: '',
level: 1,
parent_span_id: '0000000000000r00',
references: [],
resource: { 'service.name': 'travel-agent' },
sub_tree_node_count: 0,
trace_id: TRACE_ID,
trace_state: '',
};
export const MOCK_TRACE_THREAD_SPANS = [
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role":"system","content":"You are a research assistant. Use the search-web and get-weather tools as needed."},{"role":"user","content":[{"type":"text","text":"Search for SigNoz AI observability docs, then tell me the weather in Bengaluru."}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_opFrOeFM5pF9PnXe6lWcl0ui","toolName":"searchWeb","args":{"query":"SigNoz AI observability documentation"}},{"type":"tool-call","toolCallId":"call_HxRE0JdcM3F32SCbIu3SGk3G","toolName":"getWeather","args":{"city":"Bengaluru"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_opFrOeFM5pF9PnXe6lWcl0ui","toolName":"searchWeb","result":{"query":"SigNoz AI observability documentation","results":[{"title":"SigNoz docs","snippet":"AI observability ..."},{"title":"OTel GenAI","snippet":"semantic conventions ..."}]}},{"type":"tool-result","toolCallId":"call_HxRE0JdcM3F32SCbIu3SGk3G","toolName":"getWeather","result":{"city":"Bengaluru","temperatureC":18,"summary":"Clear"}}]}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'I found some information on SigNoz AI observability documentation. Here are a couple of relevant links:\n\n1. **SigNoz docs** - This includes details about AI observability.\n2. **OTel GenAI** - This document discusses semantic conventions related to observability.\n\nAs for the weather in Bengaluru, it is currently clear with a temperature of 18°C.',
'gen_ai.provider.name': 'openai.chat',
'gen_ai.request.model': 'gpt-4o-mini',
'gen_ai.usage.input_tokens': 243,
'gen_ai.usage.output_tokens': 77,
'signoz.gen_ai.usage.input_tokens.cost': 0.0006075000000000001,
'signoz.gn_ai.usage.output_tokens.cost': 0.0007700000000000001,
'signoz.gen_ai.usage.tokens.cost': 0.0013775,
},
duration_nano: 1770290125,
formatted_input: [
{
role: 'system',
content: [
{
type: 'text',
content:
'You are a research assistant. Use the search-web and get-weather tools as needed.',
},
],
},
{
role: 'user',
content: [
{
type: 'text',
content:
'Search for SigNoz AI observability docs, then tell me the weather in Bengaluru.',
},
],
},
{
role: 'assistant',
content: [
{
type: 'tool_call',
id: 'call_opFrOeFM5pF9PnXe6lWcl0ui',
name: 'searchWeb',
arguments: { query: 'SigNoz AI observability documentation' },
},
{
type: 'tool_call',
id: 'call_HxRE0JdcM3F32SCbIu3SGk3G',
name: 'getWeather',
arguments: { city: 'Bengaluru' },
},
],
},
{
role: 'tool',
content: [
{
type: 'tool_result',
content:
'{"query":"SigNoz AI observability documentation","results":[{"snippet":"AI observability ...","title":"SigNoz docs"},{"snippet":"semantic conventions ...","title":"OTel GenAI"}]}',
name: 'searchWeb',
toolCallId: 'call_opFrOeFM5pF9PnXe6lWcl0ui',
},
{
type: 'tool_result',
content: '{"city":"Bengaluru","summary":"Clear","temperatureC":18}',
name: 'getWeather',
toolCallId: 'call_HxRE0JdcM3F32SCbIu3SGk3G',
},
],
},
],
formatted_output: [
{
content: [
{
type: 'text',
content:
'I found some information on SigNoz AI observability documentation. Here are a couple of relevant links:\n\n1. **SigNoz docs** - This includes details about AI observability.\n2. **OTel GenAI** - This document discusses semantic conventions related to observability.\n\nAs for the weather in Bengaluru, it is currently clear with a temperature of 18°C.',
},
],
},
],
kind_string: 'Internal',
name: 'ai.generateText.doGenerate',
span_id: '6cee8072961624a0',
time_unix: 1789627365000,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"content": "A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?", "role": "user"}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'{"id":"resp_017c5eb995d99968006a199f9b483481979ca790db905506fc","created_at":1780064155.0,"error":null,"instructions":"You solve problems step by step and give a final answer.","model":"o3-mini-2025-01-31","object":"response","output":[{"id":"rs_017c5eb995d99968006a199f9bff248197978bf9193f34c29a","summary":[],"type":"reasoning","content":null},{"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"}],"status":"completed","usage":{"input_tokens":49,"input_tokens_details":{"cached_tokens":0},"output_tokens":431,"output_tokens_details":{"reasoning_tokens":128},"total_tokens":480}}',
'gen_ai.request.model': 'o3-mini-2025-01-31',
'gen_ai.usage.cache_read.input_tokens': 0,
'gen_ai.usage.input_tokens': 49,
'gen_ai.usage.output_tokens': 431,
'gen_ai.usage.reasoning.output_tokens': 128,
'signoz.gen_ai.usage.input_tokens.cost': 0.00012250000000000002,
'signoz.gen_ai.usage.output_tokens.cost': 0.0043100000000000005,
'signoz.gen_ai.usage.tokens.cost': 0.004432500000000001,
},
duration_nano: 2927763968,
formatted_input: [
{
role: 'user',
content: [
{
type: 'text',
content:
'A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?',
},
],
},
],
formatted_output: [
{ 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',
},
],
kind_string: 'Internal',
name: 'response',
span_id: '9d1af71602a60828',
time_unix: 1789627367500,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'{"messages":[{"role":"user","content":"A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?"}]}',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'{"completion":"Let x be the cost of the ball. Then the bat costs x + 1 dollars. The total cost is given as:\\n\\n x + (x + 1) = 1.10\\n\\nCombine like terms:\\n\\n 2x + 1 = 1.10\\n\\nSubtract 1 from both sides:\\n\\n 2x = 0.10\\n\\nDivide both sides by 2:\\n\\n x = 0.05\\n\\nSo, the ball costs 5 cents.","reasoning":"**Calculating ball price**\\n\\nI\'m determining the price of the ball based on a scenario where a bat and ball combined cost $1.10, with the bat costing $1 more than the ball. I set up the equation: let the cost of the ball be x, making the bat x + 1. Solving the equation x + (x + 1) = 1.10 leads me to find that x equals $0.05 for the ball. I need to confirm that this works: the ball at $0.05 and the bat at $1.05 totals $1.10.","rawRequest":{"model":"openai/o3-mini","max_completion_tokens":2000}}',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'openai/o3-mini',
'gen_ai.usage.cache_read.input_tokens': 0,
'gen_ai.usage.input_tokens': 33,
'gen_ai.usage.output_tokens': 629,
'gen_ai.usage.reasoning.output_tokens': 256,
'signoz.gen_ai.usage.input_tokens.cost': 0.00008250000000000001,
'signoz.gen_ai.usage.output_tokens.cost': 0.0062900000000000005,
'signoz.gen_ai.usage.tokens.cost': 0.0063725000000000006,
},
duration_nano: 4209000000,
formatted_input: [
{
role: 'user',
content: [
{
type: 'text',
content:
'A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?',
},
],
},
],
formatted_output: [
{
role: 'assistant',
content: [
{
type: 'thinking',
content:
"**Calculating ball price**\n\nI'm determining the price of the ball based on a scenario where a bat and ball combined cost $1.10, with the bat costing $1 more than the ball. I set up the equation: let the cost of the ball be x, making the bat x + 1. Solving the equation x + (x + 1) = 1.10 leads me to find that x equals $0.05 for the ball. I need to confirm that this works: the ball at $0.05 and the bat at $1.05 totals $1.10.",
},
{
type: 'text',
content:
'Let x be the cost of the ball. Then the bat costs x + 1 dollars. The total cost is given as:\n\n x + (x + 1) = 1.10\n\nCombine like terms:\n\n 2x + 1 = 1.10\n\nSubtract 1 from both sides:\n\n 2x = 0.10\n\nDivide both sides by 2:\n\n x = 0.05\n\nSo, the ball costs 5 cents.',
},
],
},
],
kind_string: 'Client',
name: 'LLM Generation',
span_id: 'af95cec99712ad26',
time_unix: 1789627370000,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role": "user", "parts": [{"type": "text", "content": "What\'s the weather in Bengaluru? Use the tool."}]}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'[{"role": "assistant", "parts": [], "finish_reason": "tool_calls"}]',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o-mini',
'gen_ai.usage.input_tokens': 66,
'gen_ai.usage.output_tokens': 18,
'signoz.gen_ai.usage.input_tokens.cost': 0.00016500000000000003,
'signoz.gen_ai.usage.output_tokens.cost': 0.00018,
'signoz.gen_ai.usage.tokens.cost': 0.00034500000000000004,
},
duration_nano: 1652955648,
formatted_input: [
{
role: 'user',
content: [
{
type: 'text',
content: "What's the weather in Bengaluru? Use the tool.",
},
],
},
],
formatted_output: [
{ role: 'assistant', content: [], finishReason: 'tool_call' },
],
kind_string: 'Internal',
name: 'litellm_request',
span_id: 'adfbedf6477f7f24',
time_unix: 1789627372500,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'{"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'The current weather in Bengaluru is partly cloudy with a temperature of 28°C.',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o-mini',
},
duration_nano: 939838834,
formatted_input: [
{
content: [
{
type: 'generic',
content:
'{"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}',
},
],
},
],
formatted_output: [
{
content: [
{
type: 'text',
content:
'The current weather in Bengaluru is partly cloudy with a temperature of 28°C.',
},
],
},
],
kind_string: 'Server',
name: '/openai/v1/chat/completions',
span_id: '0ed3e2f860c741b9',
time_unix: 1789627375000,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages': `[{"role": "user", "parts": [{"type": "text", "text": "What animal is in this image? One sentence."}, {"type": "image_url", "image_url": {"url": "${IMAGE_DATA_URL}"}}]}]`,
'gen_ai.operation.name': 'chat',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o-mini',
},
duration_nano: 412100096,
formatted_input: [
{
role: 'user',
content: [
{
type: 'text',
content: 'What animal is in this image? One sentence.',
},
{
type: 'generic',
content: `{"image_url":{"url":"${IMAGE_DATA_URL}"},"type":"image_url"}`,
},
],
},
],
has_error: true,
kind_string: 'Internal',
name: 'litellm_request',
span_id: '61c74044c542ed88',
status_code: 2,
status_code_string: 'Error',
time_unix: 1789627377500,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role": "user", "parts": [{"type": "text", "content": "Stream a short haiku about telemetry."}]}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'[{"role": "unknown", "parts": [{"type": "text", "content": "Data whispers loud, \\nSignals traverse through the night, \\nTruth in every byte."}], "finish_reason": "stop"}]',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o-mini',
'gen_ai.usage.cache_read.input_tokens': 0,
'gen_ai.usage.input_tokens': 15,
'gen_ai.usage.output_tokens': 17,
'signoz.gen_ai.usage.input_tokens.cost': 0.000037500000000000003,
'signoz.gen_ai.usage.output_tokens.cost': 0.00017,
'signoz.gen_ai.usage.tokens.cost': 0.00020750000000000003,
},
duration_nano: 1129929000,
formatted_input: [
{
role: 'user',
content: [
{ type: 'text', content: 'Stream a short haiku about telemetry.' },
],
},
],
formatted_output: [
{
role: 'unknown',
content: [
{
type: 'text',
content:
'Data whispers loud, \nSignals traverse through the night, \nTruth in every byte.',
},
],
finishReason: 'stop',
},
],
kind_string: 'Client',
name: 'ChatOpenAI.chat',
span_id: 'e9d08ec1ac3417d5',
time_unix: 1789627380000,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages': JSON.stringify([
{ role: 'system', content: OBSERVABILITY_PROMPT },
{
role: 'user',
content: [
{ type: 'text', text: 'In one sentence, what is observability?' },
],
},
]),
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
"Observability is the ability to understand a system's internal state through the telemetry it emits, such as traces, metrics, and logs.",
'gen_ai.provider.name': 'openai.chat',
'gen_ai.request.model': 'gpt-4o-mini',
'gen_ai.usage.input_tokens': 1371,
'gen_ai.usage.output_tokens': 27,
'signoz.gen_ai.usage.input_tokens.cost': 0.0034275000000000004,
'signoz.gen_ai.usage.output_tokens.cost': 0.00027,
'signoz.gen_ai.usage.tokens.cost': 0.0036975000000000003,
},
duration_nano: 959577625,
formatted_input: [
{
role: 'system',
content: [{ type: 'text', content: OBSERVABILITY_PROMPT }],
},
{
role: 'user',
content: [
{ type: 'text', content: 'In one sentence, what is observability?' },
],
},
],
formatted_output: [
{
content: [
{
type: 'text',
content:
"Observability is the ability to understand a system's internal state through the telemetry it emits, such as traces, metrics, and logs.",
},
],
},
],
kind_string: 'Internal',
name: 'ai.generateText.doGenerate',
span_id: '6bf32b44d3456393',
time_unix: 1789627382500,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role":"user","content":"List three things to pack for Paris in October."}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'[{"role":"assistant","content":"1. A light waterproof jacket\\n2. Comfortable walking shoes\\n3. A","finish_reason":"length"}]',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.usage.input_tokens': 18,
'gen_ai.usage.output_tokens': 16,
'signoz.gen_ai.usage.input_tokens.cost': 0.000045,
'signoz.gen_ai.usage.output_tokens.cost': 0.00016,
'signoz.gen_ai.usage.tokens.cost': 0.00020500000000000002,
},
duration_nano: 900000000,
formatted_input: [
{
role: 'user',
content: [
{
type: 'text',
content: 'List three things to pack for Paris in October.',
},
],
},
],
formatted_output: [
{
role: 'assistant',
content: [
{
type: 'text',
content:
'1. A light waterproof jacket\n2. Comfortable walking shoes\n3. A',
},
],
finishReason: 'length',
},
],
kind_string: 'Client',
name: 'chat gpt-4o',
span_id: '5e00000000000001',
time_unix: 1789627385000,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role":"user","content":"Write a fake 5-star review for my hotel."}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'[{"role":"assistant","content":null,"refusal":"I can\'t help write fake reviews.","finish_reason":"content_filter"}]',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.usage.input_tokens': 15,
'gen_ai.usage.output_tokens': 9,
'signoz.gen_ai.usage.input_tokens.cost': 0.000037500000000000003,
'signoz.gen_ai.usage.output_tokens.cost': 0.00009,
'signoz.gen_ai.usage.tokens.cost': 0.0001275,
},
duration_nano: 900000000,
formatted_input: [
{
role: 'user',
content: [
{ type: 'text', content: 'Write a fake 5-star review for my hotel.' },
],
},
],
formatted_output: [
{
role: 'assistant',
content: [{ type: 'text', content: "I can't help write fake reviews." }],
finishReason: 'content_filter',
},
],
kind_string: 'Client',
name: 'chat gpt-4o',
span_id: '5e00000000000002',
time_unix: 1789627387500,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":[{"type":"text","text":"rate limited, retry after 30s"}],"is_error":true}]}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'[{"role":"assistant","content":[{"type":"text","text":"The booking API is rate limited, retrying."},{"type":"tool_use","id":"toolu_02","name":"book_hotel","input":{"city":"Paris","nights":2}}],"stop_reason":"tool_use"}]',
'gen_ai.provider.name': 'anthropic',
'gen_ai.request.model': 'claude-sonnet-4',
'gen_ai.usage.input_tokens': 210,
'gen_ai.usage.output_tokens': 48,
'signoz.gen_ai.usage.input_tokens.cost': 0.0005250000000000001,
'signoz.gen_ai.usage.output_tokens.cost': 0.00048000000000000007,
'signoz.gen_ai.usage.tokens.cost': 0.001005,
},
duration_nano: 900000000,
formatted_input: [
{
role: 'user',
content: [
{
type: 'tool_result',
content: 'rate limited, retry after 30s',
toolCallId: 'toolu_01',
isError: true,
},
],
},
],
formatted_output: [
{
role: 'assistant',
content: [
{ type: 'text', content: 'The booking API is rate limited, retrying.' },
{
type: 'tool_call',
id: 'toolu_02',
name: 'book_hotel',
arguments: { city: 'Paris', nights: 2 },
},
],
finishReason: 'tool_call',
},
],
kind_string: 'Client',
name: 'chat claude-sonnet-4',
span_id: '5e00000000000003',
time_unix: 1789627390000,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role":"system","content":"Reply with JSON only."},{"role":"user","content":"Extract city and nights: two nights in Paris"}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'[{"role":"assistant","content":"{\\"city\\":\\"Paris\\",\\"nights\\":2}","finish_reason":"stop"}]',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o-mini',
'gen_ai.usage.input_tokens': 31,
'gen_ai.usage.output_tokens': 11,
'signoz.gen_ai.usage.input_tokens.cost': 0.0000775,
'signoz.gen_ai.usage.output_tokens.cost': 0.00011,
'signoz.gen_ai.usage.tokens.cost': 0.0001875,
},
duration_nano: 900000000,
formatted_input: [
{
role: 'system',
content: [{ type: 'text', content: 'Reply with JSON only.' }],
},
{
role: 'user',
content: [
{
type: 'text',
content: 'Extract city and nights: two nights in Paris',
},
],
},
],
formatted_output: [
{
role: 'assistant',
content: [{ type: 'text', content: '{"city":"Paris","nights":2}' }],
finishReason: 'stop',
},
],
kind_string: 'Client',
name: 'chat gpt-4o-mini',
span_id: '5e00000000000004',
time_unix: 1789627392500,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role":"user","content":"Weather in Paris and Tokyo?"}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'[{"role":"assistant","content":null,"tool_calls":[{"id":"call_a","type":"function","function":{"name":"get_weather","arguments":"{\\"city\\":\\"Paris\\"}"}},{"id":"call_b","type":"function","function":{"name":"get_weather","arguments":"{\\"city\\":\\"Tok"}}],"finish_reason":"tool_calls"}]',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.usage.input_tokens': 40,
'gen_ai.usage.output_tokens': 30,
'signoz.gen_ai.usage.input_tokens.cost': 0.0001,
'signoz.gen_ai.usage.output_tokens.cost': 0.00030000000000000003,
'signoz.gen_ai.usage.tokens.cost': 0.0004,
},
duration_nano: 900000000,
formatted_input: [
{
role: 'user',
content: [{ type: 'text', content: 'Weather in Paris and Tokyo?' }],
},
],
formatted_output: [
{
role: 'assistant',
content: [
{
type: 'tool_call',
id: 'call_a',
name: 'get_weather',
arguments: { city: 'Paris' },
},
{
type: 'tool_call',
id: 'call_b',
name: 'get_weather',
arguments: '{"city":"Tok',
},
],
finishReason: 'tool_call',
},
],
kind_string: 'Client',
name: 'chat gpt-4o',
span_id: '5e00000000000005',
time_unix: 1789627395000,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role":"user","content":"Summarise the trip plan."}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'[{"role":"assistant","content":[{"type":"text","text":"Paris, 2 nights, arriving"}],"stop_reason":"pause_turn"}]',
'gen_ai.provider.name': 'anthropic',
'gen_ai.request.model': 'claude-sonnet-4',
'gen_ai.usage.input_tokens': 22,
'gen_ai.usage.output_tokens': 7,
'signoz.gen_ai.usage.input_tokens.cost': 0.000055,
'signoz.gen_ai.usage.output_tokens.cost': 0.00007000000000000001,
'signoz.gen_ai.usage.tokens.cost': 0.000125,
},
duration_nano: 900000000,
formatted_input: [
{
role: 'user',
content: [{ type: 'text', content: 'Summarise the trip plan.' }],
},
],
formatted_output: [
{
role: 'assistant',
content: [{ type: 'text', content: 'Paris, 2 nights, arriving' }],
finishReason: 'pause_turn',
},
],
kind_string: 'Client',
name: 'chat claude-sonnet-4',
span_id: '5e00000000000006',
time_unix: 1789627397500,
},
{
...SPAN_DEFAULTS,
attributes: {
'gen_ai.input.messages':
'[{"role":"user","content":"Confirm the booking."}]',
'gen_ai.operation.name': 'chat',
'gen_ai.output.messages':
'[{"role":"assistant","content":"Booking failed upstream.","finish_reason":"error"}]',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.usage.input_tokens': 12,
'gen_ai.usage.output_tokens': 5,
'signoz.gen_ai.usage.input_tokens.cost': 0.000030000000000000004,
'signoz.gen_ai.usage.output_tokens.cost': 0.00005,
'signoz.gen_ai.usage.tokens.cost': 0.00008,
},
duration_nano: 900000000,
formatted_input: [
{
role: 'user',
content: [{ type: 'text', content: 'Confirm the booking.' }],
},
],
formatted_output: [
{
role: 'assistant',
content: [{ type: 'text', content: 'Booking failed upstream.' }],
finishReason: 'error',
},
],
has_error: true,
kind_string: 'Client',
name: 'chat gpt-4o',
span_id: '5e00000000000007',
status_code: 2,
status_code_string: 'Error',
time_unix: 1789627400000,
},
];

View File

@@ -0,0 +1,62 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
export type MessagePart =
| { type: 'text'; content: string }
| { type: 'thinking'; content?: string }
| {
type: 'tool_call';
id: string;
name: string;
arguments: unknown;
server?: boolean;
}
| {
type: 'tool_result';
toolCallId: string;
name?: string;
content: string;
isError?: boolean;
}
| {
type: 'media';
modality?: string;
mimeType?: string;
uri?: string;
name?: string;
sizeBytes?: number;
}
| { type: 'generic'; content: string };
export interface ThreadMessage {
// system | user | assistant | tool; any other value renders headerless.
role?: string;
content: MessagePart[];
// stop | tool_call | length | content_filter | error, or provider-specific.
finishReason?: string;
}
export type ThreadSpan = SpanV3 & {
formatted_input?: ThreadMessage[];
formatted_output?: ThreadMessage[];
};
/** Response of GET /api/v1/traces/{id}/thread. */
export interface TraceThreadResponse {
spans: ThreadSpan[];
prevCursor?: string;
nextCursor?: string;
}
export enum ThreadView {
Formatted = 'formatted',
Json = 'json',
}
export enum AnchorStatus {
None = 'none',
Loading = 'loading',
Found = 'found',
// Backend anchors on the span's position but drops it for lacking messages.
NoMessages = 'noMessages',
NotFound = 'notFound',
}

View File

@@ -0,0 +1,30 @@
import { collectToolCallIds } from '../AIThreadMessage/utils';
import { AnchorStatus, ThreadSpan } from './types';
export function buildToolCallIndex(spans: ThreadSpan[]): Set<string> {
return new Set(
spans.flatMap((span) => [
...collectToolCallIds(span.formatted_input ?? []),
...collectToolCallIds(span.formatted_output ?? []),
]),
);
}
export function getAnchorStatus(
anchorSpanId: string | undefined,
spans: ThreadSpan[],
isLoading: boolean,
): AnchorStatus {
if (!anchorSpanId) {
return AnchorStatus.None;
}
if (isLoading) {
return AnchorStatus.Loading;
}
if (spans.length === 0) {
return AnchorStatus.NotFound;
}
return spans.some((span) => span.span_id === anchorSpanId)
? AnchorStatus.Found
: AnchorStatus.NoMessages;
}

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,56 @@
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',
inputMessages: 'gen_ai.input.messages',
outputMessages: 'gen_ai.output.messages',
} 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

@@ -84,23 +84,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.GetTraceThreadParams),
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

@@ -93,31 +93,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) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
req := new(spantypes.GetTraceThreadParams)
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(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"], query)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -189,49 +189,6 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
), nil
}
func (m *module) GetThread(ctx context.Context, orgID valuer.UUID, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error) {
bounds, err := m.store.GetTraceBounds(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, bounds, query.SpanID)
if err != nil {
return nil, err
}
before, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, 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, orgID, traceID, bounds, 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, orgID, traceID, bounds, 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, orgID, traceID, bounds, page)
if err != nil {
return nil, err
}
return spantypes.NewGettableTraceThread(traceID, query, nil, after), nil
}
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, bounds.Start, bounds.End, nil)
if err != nil {

View File

@@ -158,6 +158,10 @@ func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, trace
// genAISpanColumns returns the gen_ai columns aggregated per span, resolved across attribute evolutions.
func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) ([]string, error) {
attributeKey := func(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
}
values := []struct{ key, alias string }{
{aiobservabilitytypes.GenAIUsageInputTokens, "input_tokens_value"},
{aiobservabilitytypes.GenAIUsageOutputTokens, "output_tokens_value"},
@@ -171,17 +175,26 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
for _, value := range values {
names = append(names, value.key)
}
keys, err := s.genAIFieldKeys(ctx, orgID, names)
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
for _, name := range names {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
}
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
if err != nil {
return nil, err
}
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
isGenAI, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
if err != nil {
return nil, err
gate := make([]string, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
conds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
if err != nil {
return nil, err
}
gate = append(gate, conds...)
}
columns := []string{isGenAI + " AS is_gen_ai"}
columns := []string{sb.Or(gate...) + " AS is_gen_ai"}
for _, value := range values {
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
@@ -254,7 +267,8 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "has_error", "kind",
"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",
@@ -284,92 +298,6 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
return spans, nil
}
func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds, page spantypes.ThreadPage) ([]spantypes.StorableSpan, error) {
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "parent_span_id", "has_error", "name", "kind_string",
"status_code_string", "status_message", "resources_string",
"attributes_string", "attributes_number", "attributes_bool",
"events", "links as references",
)
if q.TraceAttrsJSONOn {
sb.SelectMore("attributes")
}
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
messageKeys := []string{aiobservabilitytypes.GenAIInputMessages, aiobservabilitytypes.GenAIOutputMessages}
keys, err := s.genAIFieldKeys(ctx, orgID, messageKeys)
if err != nil {
return nil, err
}
hasMessages, err := s.anyExistsCondition(ctx, q, messageKeys, keys, sb)
if err != nil {
return nil, err
}
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
sb.LE("ts_bucket_start", bounds.End.Unix()),
hasMessages,
)
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, bounds *spantypes.TraceBounds, 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", bounds.Start.Unix()-1800),
sb.LE("ts_bucket_start", bounds.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(
@@ -492,34 +420,3 @@ func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string,
}
return result, nil
}
func attributeKey(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
}
// genAIFieldKeys fetches the metadata keys for the attribute names, expanded across semconv families.
func (s *traceStore) genAIFieldKeys(ctx context.Context, orgID valuer.UUID, names []string) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
for _, name := range names {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
}
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error fetching gen_ai field keys")
}
return keys, nil
}
// anyExistsCondition ORs an EXISTS test per attribute name, resolved across attribute evolutions
// and the use_trace_attributes_json flag so the filter reads the same columns the query builder does.
func (s *traceStore) anyExistsCondition(ctx context.Context, q qbtypes.QueryInfo, names []string, keys map[string][]*telemetrytypes.TelemetryFieldKey, sb *sqlbuilder.SelectBuilder) (string, error) {
conds := make([]string, 0, len(names))
for _, name := range names {
keyConds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
if err != nil {
return "", err
}
conds = append(conds, keyConds...)
}
return sb.Or(conds...), nil
}

View File

@@ -202,43 +202,3 @@ func TestGetSpanDurationByField(t *testing.T) {
})
}
}
func TestGetThreadSpans(t *testing.T) {
selectSQL := "SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, parent_span_id, has_error, name, kind_string, status_code_string, status_message, resources_string, attributes_string, attributes_number, attributes_bool, events, links as references"
fromSQL := " FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND "
orderSQL := " ORDER BY timestamp ASC, span_id ASC LIMIT ?"
jsonInsideTrace := testStart.Add(500 * time.Second)
testCases := []struct {
name string
jsonOn bool
jsonRelease *time.Time
selectSQL string
whereSQL string
}{
{
name: "FlagOff_ReadsAndFiltersLegacyMaps",
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL,
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages'))",
},
{
name: "FlagOn_ReleasedDuringTrace_ReadsJSONFiltersJSONThenMaps",
jsonOn: true,
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL + ", attributes",
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureUseTraceAttributesJSON.String(): testCase.jsonOn})
s := newTestStoreWithMetadata(sqlmock.QueryMatcherRegexp, genAIMetadataStore(testCase.jsonRelease), fl)
s.Mock().ExpectSelect(regexp.QuoteMeta(testCase.selectSQL + fromSQL + testCase.whereSQL + orderSQL)).
WillReturnRows(cmock.NewRows(nil, nil))
_, _ = s.Store().GetThreadSpans(context.Background(), valuer.GenerateUUID(), testTraceID, testBounds, spantypes.ThreadPage{Limit: 3})
assert.NoError(t, s.Mock().ExpectationsWereMet())
})
}
}

View File

@@ -15,7 +15,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.
@@ -24,5 +23,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, orgID valuer.UUID, 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)
}
})
}
}

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