Compare commits

...

22 Commits

Author SHA1 Message Date
Gaurav Tewari
e2e2d04bcd feat: update keys 2026-10-08 14:58:28 +05:30
Gaurav Tewari
9867e2860f chore: refactor tooltip title 2026-10-08 13:26:47 +05:30
Gaurav Tewari
e44ab4ff13 chore: update tooltip section 2026-10-08 12:02:00 +05:30
Gaurav Tewari
66ddc7c4d2 feat: add support for span details and minor refactor 2026-10-08 11:32:17 +05:30
Gaurav Tewari
b2c36b3947 chore: update tooltip styles 2026-10-08 10:40:59 +05:30
Gaurav Tewari
d5948adc8e Merge branch 'feat/trace-summary-api' into feat/trace-waterfall-tooltip 2026-10-08 00:22:18 +05:30
Gaurav Tewari
fe8aebc15a Merge remote-tracking branch 'refs/remotes/origin/main' into feat/trace-summary-api 2026-10-08 00:21:56 +05:30
Gaurav Tewari
27a635c40c chore: self review change 2026-10-07 22:41:43 +05:30
Gaurav Tewari
6099faf30c chore: minor css update 2026-10-07 22:02:31 +05:30
Gaurav Tewari
050c295179 feat: minor changes 2026-10-07 21:25:32 +05:30
Gaurav Tewari
e5e0f169ff feat: update css 2026-10-07 20:47:12 +05:30
Gaurav Tewari
1cd716d3ad refactor: self review changes 2026-10-07 19:52:59 +05:30
Gaurav Tewari
78ecfb7bc7 feat: update ai logic. we don't need to calcuate things on frontend 2026-10-07 19:07:56 +05:30
Gaurav Tewari
38cf950ce7 test(trace-details): use renamed header options prop in AI usage tests
Assisted-by: Claude Opus 5.5
2026-10-07 18:32:38 +05:30
Gaurav Tewari
e5de83cc31 feat(trace-details): share tooltip styles and show AI tokens and cost in the header
Assisted-by: Claude Opus 5.5
2026-10-07 18:29:58 +05:30
Gaurav Tewari
5b0a409751 feat: update badge color 2026-10-07 18:29:57 +05:30
Gaurav Tewari
bcd222f046 feat: add support for badge and update ai icons 2026-10-07 18:29:57 +05:30
Gaurav Tewari
faa92bb38c chore: update tooltip design and support ai spans 2026-10-07 18:29:57 +05:30
Gaurav Tewari
0ae98b05c5 feat: loading in skeleton 2026-10-07 17:51:36 +05:30
Gaurav Tewari
7c76ba8d9e feat: update header options 2026-10-07 17:35:39 +05:30
Gaurav Tewari
104758f3cf refactor(trace-details): derive header filter range from the trace summary
Assisted-by: Claude Opus 5.5
2026-10-07 17:15:20 +05:30
Gaurav Tewari
504be0dfd7 feat(trace-details): read header metadata from the trace summary API
Assisted-by: Claude Opus 5.5
2026-10-07 16:59:38 +05:30
35 changed files with 1157 additions and 176 deletions

View File

@@ -1,11 +1,12 @@
import { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import TraceTooltip from '../TooltipPrimitives/TraceTooltip';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: string;
tooltip: ReactNode;
icon?: ReactNode;
children: ReactNode;
}
@@ -16,12 +17,12 @@ function EntityMetadataItem({
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<TooltipSimple title={tooltip}>
<TraceTooltip title={tooltip}>
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>
</span>
</TooltipSimple>
</TraceTooltip>
);
}

View File

@@ -8,3 +8,12 @@
// 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 {
composes: tooltipBody from '../shared.module.scss';
min-width: 160px;
}
.tokenTooltipTitle {
padding-bottom: var(--spacing-4);
}

View File

@@ -1,10 +1,17 @@
import { CalendarClock, Server, Timer } from '@signozhq/icons';
import { CalendarClock, Coins, Landmark, 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 {
AiTokenCounts,
formatCost,
formatTokens,
hasValue,
} from '../utils/genAi';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import styles from './EntityMetadataRow.module.scss';
@@ -16,6 +23,8 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: AiTokenCounts;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -32,6 +41,8 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const durationTooltip =
@@ -91,6 +102,25 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{hasValue(tokens?.totalInput) && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(tokens.totalInput)}
{hasValue(tokens.output) && ` → ${formatTokens(tokens.output)}`}
</EntityMetadataItem>
)}
{hasValue(cost) && (
<EntityMetadataItem
tooltip="Total cost"
icon={<Landmark size={ICON_SIZE} />}
>
Cost — {formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -102,6 +132,8 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -0,0 +1,35 @@
import { Typography } from '@signozhq/ui/typography';
import TokenBreakdown from '../TooltipPrimitives/TokenBreakdown';
import { AiTokenCounts } from '../utils/genAi';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: AiTokenCounts;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { totalInput, input, output, cacheRead, cacheWrite } = tokens;
return (
<div className={styles.tokenTooltip}>
<Typography.Text
size="small"
weight="medium"
className={styles.tokenTooltipTitle}
>
Usage Breakdown
</Typography.Text>
<TokenBreakdown
totalInput={totalInput}
input={input}
output={output}
cacheRead={cacheRead}
cacheWrite={cacheWrite}
/>
</div>
);
}
export default TokenUsageTooltip;

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 { getAIUsageDetails, convertToAiTokens } from '../SpanHoverCard/aiUsage';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
LinkedSpansPanel,
@@ -31,6 +33,10 @@ function SpanSummary({
}: SpanSummaryProps): JSX.Element {
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
const aiUsage = useMemo(
() => getAIUsageDetails(selectedSpan)?.usage,
[selectedSpan],
);
return (
<>
@@ -63,6 +69,8 @@ function SpanSummary({
: undefined
}
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
tokens={aiUsage && convertToAiTokens(aiUsage)}
cost={aiUsage?.cost}
/>
<div className={styles.spanInfoItem}>

View File

@@ -95,4 +95,27 @@ describe('SpanSummary', () => {
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
});
it('renders tokens and cost from the gen_ai usage attributes', () => {
const span = {
...createMockSpan(),
attributes: {
'signoz.gen_ai.usage.total_input_tokens': 1200,
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 9,
'signoz.gen_ai.usage.tokens.cost': 0.000009,
},
};
render(<SpanSummary selectedSpan={span} />);
expect(screen.getByText(/Tokens: 1,200 → 9/)).toBeInTheDocument();
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
});
it('omits tokens and cost for non-AI spans', () => {
render(<SpanSummary selectedSpan={createMockSpan()} />);
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
});
});

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

@@ -8,16 +8,12 @@
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l2-foreground);
margin-bottom: 6px;
}
.name {

View File

@@ -9,40 +9,23 @@
}
.popover {
composes: content from '../TooltipPrimitives/TooltipSurface.module.scss';
// 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);
composes: tooltipBody from '../shared.module.scss';
width: 223px;
}
.name {
font-weight: 600;
margin-bottom: 4px;
.title {
composes: tooltipTitle from '../shared.module.scss';
}
.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;
padding-bottom: var(--spacing-2);
}

View File

@@ -4,6 +4,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
@@ -12,6 +13,11 @@ import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { getAIUsageDetails, SpanAiDetails } from './aiUsage';
import SpanUsageBreakdown from './SpanUsageBreakdown';
import TooltipRow from '../TooltipPrimitives/TooltipRow';
import TooltipSection from '../TooltipPrimitives/TooltipSection';
import styles from './SpanHoverCard.module.scss';
/**
@@ -38,6 +44,7 @@ export interface SpanTooltipContentProps {
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
ai?: SpanAiDetails;
}
export function SpanTooltipContent({
@@ -47,33 +54,51 @@ 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={styles.header}>
<Typography.Text
className={styles.title}
size="small"
weight="medium"
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 +162,7 @@ export function SpanHoverCard({
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
ai: getAIUsageDetails(span),
},
};
}, [

View File

@@ -0,0 +1,39 @@
import { formatCost } from '../utils/genAi';
import { SpanAiUsage } from './aiUsage';
import TokenBreakdown from '../TooltipPrimitives/TokenBreakdown';
import TooltipRow from '../TooltipPrimitives/TooltipRow';
import TooltipSection from '../TooltipPrimitives/TooltipSection';
interface SpanUsageBreakdownProps {
usage: SpanAiUsage;
}
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
const {
totalInputTokens,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cost,
} = usage;
return (
<>
<TokenBreakdown
totalInput={totalInputTokens}
input={inputTokens}
output={outputTokens}
cacheRead={cacheReadTokens}
cacheWrite={cacheCreationTokens}
/>
{cost !== undefined && (
<TooltipSection>
<TooltipRow label="cost" value={formatCost(cost)} />
</TooltipSection>
)}
</>
);
}
export default SpanUsageBreakdown;

View File

@@ -0,0 +1,166 @@
import { render, screen } from '@testing-library/react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAIUsageDetails } 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;
describe('getAIUsageDetails', () => {
it('returns undefined for a span without gen_ai attributes', () => {
expect(getAIUsageDetails(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
});
it('reads model, tool, agent, token usage and cost', () => {
expect(
getAIUsageDetails(
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: {
totalInputTokens: undefined,
inputTokens: 120,
outputTokens: 30,
cacheReadTokens: undefined,
cacheCreationTokens: undefined,
reasoningTokens: undefined,
cost: 0.00000056,
},
});
});
});
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 raw input, cache and output tokens with cost', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{
model: 'gpt-4o',
usage: {
inputTokens: 1000,
outputTokens: 200,
cacheReadTokens: 600,
cacheCreationTokens: 100,
cost: 0.00000056,
},
}}
/>,
);
expect(screen.getByText('Usage Breakdown')).toBeInTheDocument();
expect(screen.getByText('Input')).toBeInTheDocument();
expect(screen.getByText('1,000')).toBeInTheDocument();
expect(screen.getByText('Cache')).toBeInTheDocument();
expect(screen.getByText('Cache Read')).toBeInTheDocument();
expect(screen.getByText('600')).toBeInTheDocument();
expect(screen.getByText('Cache Write')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('Output')).toBeInTheDocument();
expect(screen.getByText('200')).toBeInTheDocument();
// Raw values only: no derived input or total usage.
expect(screen.queryByText('1,700')).not.toBeInTheDocument();
expect(screen.queryByText('1,200')).not.toBeInTheDocument();
expect(screen.getByText('$ 0.00000056')).toBeInTheDocument();
});
it('shows cache rows whose value is zero', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{
usage: {
inputTokens: 194,
outputTokens: 32,
cacheReadTokens: 0,
cacheCreationTokens: 0,
},
}}
/>,
);
expect(screen.getByText('Cache')).toBeInTheDocument();
expect(screen.getByText('Cache Read')).toBeInTheDocument();
expect(screen.getByText('Cache Write')).toBeInTheDocument();
});
it('nests cache and raw input under total input, and adds a total', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{
usage: {
totalInputTokens: 1250,
inputTokens: 1000,
cacheReadTokens: 200,
cacheCreationTokens: 50,
outputTokens: 30,
},
}}
/>,
);
expect(screen.getAllByText('Input')).toHaveLength(2);
expect(screen.getByText('1,250')).toBeInTheDocument();
expect(screen.getByText('1,000')).toBeInTheDocument();
expect(screen.getByText('Cache Read')).toBeInTheDocument();
expect(screen.getByText('Cache Write')).toBeInTheDocument();
expect(screen.queryByText('Cache')).not.toBeInTheDocument();
expect(screen.getByText('Total')).toBeInTheDocument();
expect(screen.getByText('1,280')).toBeInTheDocument();
});
it('shows no total without totalInputTokens', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{ usage: { inputTokens: 10, outputTokens: 5 } }}
/>,
);
expect(screen.queryByText('Total')).not.toBeInTheDocument();
});
it('hides cache rows and cost when the attributes are absent', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{ usage: { inputTokens: 10, outputTokens: 5 } }}
/>,
);
expect(screen.queryByText('Cache')).not.toBeInTheDocument();
expect(screen.queryByText('Cache Read')).not.toBeInTheDocument();
expect(screen.queryByText('Cache Write')).not.toBeInTheDocument();
expect(screen.queryByText('cost')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,81 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { AiTokenCounts, GEN_AI_KEYS, getGenAiValue } from '../utils/genAi';
export interface SpanAiUsage {
totalInputTokens?: number;
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
reasoningTokens?: number;
cost?: number;
}
export interface SpanAiDetails {
model?: string;
toolName?: string;
agentName?: string;
usage?: SpanAiUsage;
}
function getGenAiAttributeAsString(
span: SpanV3,
key: string,
): string | undefined {
const value = getGenAiValue(span, key);
return value === undefined ? undefined : String(value);
}
function getGenAiAttributeAsNumber(
span: SpanV3,
key: string,
): number | undefined {
const value = getGenAiValue(span, key);
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
export function getAIUsageDetails(span: SpanV3): SpanAiDetails | undefined {
const usage: SpanAiUsage = {
totalInputTokens: getGenAiAttributeAsNumber(
span,
GEN_AI_KEYS.totalInputTokens,
),
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
cacheCreationTokens: getGenAiAttributeAsNumber(
span,
GEN_AI_KEYS.cacheCreationTokens,
),
reasoningTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.reasoningTokens),
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
};
const hasUsage = Object.values(usage).some((value) => value !== undefined);
const details: SpanAiDetails = {
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
usage: hasUsage ? usage : undefined,
};
return details.model || details.toolName || details.agentName || hasUsage
? details
: undefined;
}
export function convertToAiTokens(usage: SpanAiUsage): AiTokenCounts {
return {
totalInput: usage.totalInputTokens,
input: usage.inputTokens,
output: usage.outputTokens,
cacheRead: usage.cacheReadTokens,
cacheWrite: usage.cacheCreationTokens,
reasoning: usage.reasoningTokens,
};
}

View File

@@ -0,0 +1,83 @@
import { AiTokenCounts, formatTokens, hasValue } from '../utils/genAi';
import TooltipRow from './TooltipRow';
import TooltipSection from './TooltipSection';
type TokenBreakdownProps = Omit<AiTokenCounts, 'reasoning'>;
function TokenBreakdown({
totalInput,
input,
output,
cacheRead,
cacheWrite,
}: TokenBreakdownProps): JSX.Element {
const showCacheRead = hasValue(cacheRead);
const showCacheWrite = hasValue(cacheWrite);
const cacheRows = (
<>
{showCacheRead && (
<TooltipRow label="Cache Read" value={formatTokens(cacheRead)} isNested />
)}
{showCacheWrite && (
<TooltipRow label="Cache Write" value={formatTokens(cacheWrite)} isNested />
)}
</>
);
const outputSection = hasValue(output) && (
<TooltipSection>
<TooltipRow label="Output" value={formatTokens(output)} isHeading />
</TooltipSection>
);
// Without totalInput the cache mode is unknown, so cache can't be shown as part of input.
if (!hasValue(totalInput)) {
return (
<>
{hasValue(input) && (
<TooltipSection>
<TooltipRow label="Input" value={formatTokens(input)} isHeading />
</TooltipSection>
)}
{(showCacheRead || showCacheWrite) && (
<TooltipSection>
<TooltipRow label="Cache" isHeading />
{cacheRows}
</TooltipSection>
)}
{outputSection}
</>
);
}
return (
<>
<TooltipSection>
<TooltipRow label="Input" value={formatTokens(totalInput)} isHeading />
{cacheRows}
{hasValue(input) && (
<TooltipRow label="Input" value={formatTokens(input)} isNested />
)}
</TooltipSection>
{outputSection}
{hasValue(output) && (
<TooltipSection>
<TooltipRow
label="Total"
value={formatTokens(totalInput + output)}
isHeading
/>
</TooltipSection>
)}
</>
);
}
TokenBreakdown.defaultProps = {
totalInput: undefined,
input: undefined,
output: undefined,
cacheRead: undefined,
cacheWrite: undefined,
};
export default TokenBreakdown;

View File

@@ -0,0 +1,41 @@
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-2) 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: var(--spacing-2);
min-width: 0;
}
.nestedRow {
padding-left: var(--spacing-6);
}
.rowValue {
flex-shrink: 0;
max-width: 60%;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,42 @@
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './TooltipPrimitives.module.scss';
interface TooltipRowProps {
label: string;
value?: string;
isNested?: boolean;
isHeading?: boolean;
testId?: string;
}
function TooltipRow({
label,
value,
isNested,
isHeading,
testId,
}: TooltipRowProps): JSX.Element {
return (
<div
className={cx(styles.row, isNested && styles.nestedRow)}
data-testid={testId}
>
<Typography.Text
size="small"
color={isHeading ? undefined : 'muted'}
truncate={1}
>
{label}
</Typography.Text>
{value !== undefined && (
<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 './TooltipPrimitives.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,29 @@
/* Figma `dark/shadow-dropdown` popover surface, shared by every trace-details
tooltip so they look the same. */
/* Plain CSS only (no SCSS syntax): other modules `composes` from this file. */
/* For plain elements, e.g. the flamegraph's cursor-following portal. */
.surface {
background-color: color-mix(in srgb, var(--l3-background) 60%, transparent);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: var(--spacing-4);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
/* For @signozhq/ui Tooltip content: it paints both the bubble and the arrow
from these variables. */
.content {
--tooltip-background: color-mix(
in srgb,
var(--l3-background) 60%,
transparent
);
--tooltip-border-color: var(--l2-border);
--tooltip-border-radius: var(--radius-2);
--tooltip-padding: var(--spacing-4);
--tooltip-box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}

View File

@@ -0,0 +1,13 @@
import { HTMLAttributes } from 'react';
import cx from 'classnames';
import styles from './TooltipSurface.module.scss';
function TooltipSurface({
className,
...props
}: HTMLAttributes<HTMLDivElement>): JSX.Element {
return <div className={cx(styles.surface, className)} {...props} />;
}
export default TooltipSurface;

View File

@@ -0,0 +1,21 @@
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import styles from './TooltipSurface.module.scss';
function TraceTooltip({
tooltipContentProps,
...props
}: TooltipSimpleProps): JSX.Element {
return (
<TooltipSimple
{...props}
tooltipContentProps={{
...tooltipContentProps,
className: cx(styles.content, tooltipContentProps?.className),
}}
/>
);
}
export default TraceTooltip;

View File

@@ -27,31 +27,14 @@ import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import { useTraceSummary } from './useTraceSummary';
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;
showTraceDetailsHeaderOptions?: boolean;
}
const SKELETON_COUNT = 3;
@@ -73,16 +56,15 @@ function DetailsLoader(): JSX.Element {
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
isDataLoaded,
traceMetadata,
showTraceDetailsHeaderOptions,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV3URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const { data: traceSummary } = useTraceSummary(traceID || '');
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
@@ -116,8 +98,11 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
const durationMs = traceSummary
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
: 0;
return (
@@ -142,7 +127,7 @@ function TraceDetailsHeader({
/>
</div>
)}
{isDataLoaded && (
{showTraceDetailsHeaderOptions && traceSummary && (
<div
className={cx(
styles.filterSection,
@@ -171,9 +156,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={traceSummary.totalSpansCount}
/>
</div>
</TooltipProvider>
@@ -183,9 +168,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)}
@@ -198,18 +183,20 @@ function TraceDetailsHeader({
{showTraceDetails && (
<div className={styles.subHeader}>
{traceMetadata ? (
{traceSummary ? (
<EntityMetadataRow
entity="trace"
service={{
name: traceMetadata.rootServiceName,
entryPoint: traceMetadata.rootServiceEntryPoint,
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
}}
durationMs={durationMs}
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
timestamp={dayjs(traceSummary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceMetadata.rootSpanStatusCode}
statusCode={traceSummary.rootSpanStatusCode}
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />
@@ -217,7 +204,7 @@ function TraceDetailsHeader({
</div>
)}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -1,10 +1,16 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
jest.mock('../useTraceSummary', () => ({
useTraceSummary: jest.fn(() => ({ data: undefined, isLoading: false })),
}));
const mockGoBack = jest.fn();
const mockPush = jest.fn();
@@ -51,13 +57,19 @@ jest.mock('components/FieldsSelector', () => ({
}));
const baseProps = {
filterMetadata: {
startTime: 0,
endTime: 1,
traceId: 'trace-123',
},
onFilteredSpansChange: jest.fn(),
isDataLoaded: false,
showTraceDetailsHeaderOptions: false,
};
const SUMMARY = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000,
rootServiceName: 'frontend',
rootServiceEntryPoint: 'GET /checkout',
rootSpanStatusCode: '200',
hasMissingSpans: false,
totalSpansCount: 3,
totalErrorSpansCount: 0,
};
describe('TraceDetailsHeader – back button', () => {
@@ -92,10 +104,32 @@ describe('TraceDetailsHeader – back button', () => {
describe('TraceDetailsHeader – action cluster', () => {
beforeEach(() => {
mockReplace.mockClear();
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: SUMMARY, isLoading: false });
});
afterEach(() => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: false });
});
it('does not render the action buttons until the summary loads', () => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: true });
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
render(
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions={false} />,
);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
@@ -106,7 +140,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('renders Analytics and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(
screen.getByRole('button', { name: /^analytics$/i }),
@@ -117,7 +151,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
@@ -133,7 +167,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
describe('TraceDetailsHeader – trace metadata row', () => {
// Plain prop, no API mock needed: traceMetadata is passed straight in.
// useTraceSummary is mocked, so no API call is made.
const traceMetadata = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
@@ -142,16 +176,31 @@ describe('TraceDetailsHeader – trace metadata row', () => {
rootSpanStatusCode: '404',
hasMissingSpans: false,
totalSpansCount: 42,
totalErrorSpansCount: 0,
ai: {
tokens: {
totalInput: 12040,
input: 12040,
output: 3110,
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
},
totalCost: 0.0421,
},
};
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
};
afterEach(() => {
mockSummary(undefined);
});
it('renders the metadata (service, entry point, duration, status) when provided', () => {
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
@@ -164,15 +213,77 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(screen.getByText(duration)).toBeInTheDocument();
});
it('renders AI tokens and cost when the summary has them', () => {
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('omits AI tokens and cost when the summary has no ai field', () => {
mockSummary({ ...traceMetadata, ai: undefined });
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('shows AI tokens when totalInput and output are both zero', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, totalInput: 0, output: 0 },
},
} as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('uses totalInput as the input count when the summary sends it', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, totalInput: 15150 },
},
} as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 15,150 → 3,110')).toBeInTheDocument();
});
it('omits AI tokens when totalInput is missing', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, totalInput: undefined },
},
} as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
});
it('omits the cost when the summary sends null', () => {
mockSummary({
...traceMetadata,
ai: { ...traceMetadata.ai, totalCost: null },
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.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}
/>,
);
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
// Visible by default (showTraceDetails defaults to true).
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
@@ -192,9 +303,12 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
});
it('does not render the metadata row when traceMetadata is absent', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
it('shows skeletons instead of the metadata when the summary is absent', () => {
const { container } = render(
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />,
);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
});

View File

@@ -0,0 +1,16 @@
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
interface UseTraceSummaryResult {
data: SpantypesGettableTraceSummaryDTO | undefined;
isLoading: boolean;
}
export function useTraceSummary(traceId: string): UseTraceSummaryResult {
const { data, isLoading } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId } },
);
return { data: data?.data, isLoading };
}

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

@@ -6,6 +6,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useCrosshair } from '../hooks/useCrosshair';
import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
import TooltipSurface from '../TooltipPrimitives/TooltipSurface';
import { DEFAULT_ROW_HEIGHT } from './constants';
import { useCanvasSetup } from './hooks/useCanvasSetup';
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
@@ -202,7 +203,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const tooltipElement = tooltipContent
? createPortal(
<div
<TooltipSurface
className={styles.tooltip}
style={{
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
@@ -226,7 +227,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
previewRows={tooltipContent.previewRows}
/>
)}
</div>,
</TooltipSurface>,
document.body,
)
: null;

View File

@@ -30,12 +30,8 @@
// loads cross-module composes through the plain CSS parser, which chokes on
// SCSS `//` comments in the target file.
.popover {
composes: content from '../../../TooltipPrimitives/TooltipSurface.module.scss';
--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);
}
@@ -217,8 +213,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 +405,11 @@
}
}
.aiSpanIcon {
flex-shrink: 0;
margin: 0 var(--spacing-2);
}
.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

@@ -28,7 +28,6 @@ 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 TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
@@ -323,38 +322,6 @@ function TraceDetailsV3(): JSX.Element {
[],
);
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);
@@ -393,10 +360,10 @@ function TraceDetailsV3(): JSX.Element {
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
traceMetadata={traceMetadataForHeader}
showTraceDetailsHeaderOptions={
!!traceData?.payload?.spans?.length && !showNoData
}
/>
{showNoData ? (

View File

@@ -0,0 +1,13 @@
/* Plain CSS only (no SCSS syntax): other modules `composes` from this file. */
.tooltipBody {
display: flex;
flex-direction: column;
letter-spacing: var(--paragraph-small-400-letter-spacing);
color: var(--l1-foreground-hover);
}
.tooltipTitle {
font-family: 'SF Mono', monospace;
word-break: break-all;
}

View File

@@ -44,6 +44,7 @@ import {
traceDetailFieldKeys,
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -149,6 +150,13 @@ export const traceDetailsMocks = defineStoryMocks({
),
),
rest.get(
'http://localhost/api/v1/traces/:traceId/summary',
response.json(() =>
traceSummaryResponse({ ...trace, missingSpans: values.missingSpans }),
),
),
rest.post(
'http://localhost/api/v3/traces/:traceId/flamegraph',
response.json(() => traceFlamegraphResponse(trace)),

View File

@@ -6,6 +6,7 @@
import type {
GetFlamegraph200,
GetTraceAggregations200,
GetTraceSummary200,
GetWaterfallV4200,
SpantypesFlamegraphSpanDTO,
SpantypesSpanAggregationDTO,
@@ -303,6 +304,27 @@ export const traceWaterfallResponse = (
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
const spans = buildSpans(options);
const root = spans[0];
return {
status: 'success',
data: {
startTimestampMillis: Math.round(options.traceStart),
endTimestampMillis: Math.round(options.traceStart + ROOT_DURATION_MS),
rootServiceName: root?.template.service ?? '',
rootServiceEntryPoint: root?.template.name ?? '',
rootSpanStatusCode: root?.hasError ? '503' : '200',
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
},
};
};
const flamegraphSpan = (span: BuiltSpan): SpantypesFlamegraphSpanDTO => ({
spanId: span.spanId,
parentSpanId: span.parentSpanId,

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,83 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
export const GEN_AI_KEYS = {
requestModel: 'gen_ai.request.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',
totalInputTokens: 'signoz.gen_ai.usage.total_input_tokens',
cost: 'signoz.gen_ai.usage.tokens.cost',
} as const;
export enum AiSpanKind {
Llm = 'llm',
Tool = 'tool',
Agent = 'agent',
}
const LLM_KEYS = [
GEN_AI_KEYS.requestModel,
GEN_AI_KEYS.inputTokens,
GEN_AI_KEYS.outputTokens,
GEN_AI_KEYS.cacheReadTokens,
GEN_AI_KEYS.cacheCreationTokens,
GEN_AI_KEYS.reasoningTokens,
];
/** Keeps numeric 0, which `getSpanAttribute`'s `||` chain drops. */
export function getGenAiValue(
span: SpanV3,
key: string,
): string | number | boolean | undefined {
const value = span.attributes?.[key] ?? span.resource?.[key];
return value === '' ? undefined : value;
}
function hasAttribute(span: SpanV3, key: string): boolean {
return getGenAiValue(span, key) !== undefined;
}
/** 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;
}
/** Token counts; `null` or missing means the backend did not send the value. */
export interface AiTokenCounts {
/** Input incl. cache read/write, counted once. Only set when the model's cache mode is known. */
totalInput?: number | null;
input?: number | null;
output?: number | null;
cacheRead?: number | null;
cacheWrite?: number | null;
reasoning?: number | null;
}
export function hasValue(value: number | null | undefined): value is number {
return value !== undefined && value !== null;
}
export function formatTokens(value: number): string {
return value.toLocaleString('en-US');
}
export function formatCost(value: number): string {
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
}