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
79 changed files with 4369 additions and 710 deletions

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