Compare commits

..

24 Commits

Author SHA1 Message Date
Gaurav Tewari
920d8695c1 Merge remote-tracking branch 'origin/feat/trace-waterfall-tooltip' into feat/trace-details-tabs
# Conflicts:
#	frontend/src/pages/TraceDetailsV3/EntityMetadata/EntityMetadataItem.tsx
#	frontend/src/pages/TraceDetailsV3/EntityMetadata/EntityMetadataRow.module.scss
#	frontend/src/pages/TraceDetailsV3/EntityMetadata/EntityMetadataRow.tsx
#	frontend/src/pages/TraceDetailsV3/EntityMetadata/TokenUsageTooltip.tsx
#	frontend/src/pages/TraceDetailsV3/SpanHintBadge/SpanHintBadge.tsx
#	frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
#	frontend/src/pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard.module.scss
#	frontend/src/pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard.tsx
#	frontend/src/pages/TraceDetailsV3/SpanHoverCard/SpanUsageBreakdown.tsx
#	frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/TraceDetailsHeader.tsx
#	frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/__tests__/TraceDetailsHeader.test.tsx
#	frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/useTraceSummary.ts
#	frontend/src/pages/TraceDetailsV3/TraceTooltip/TraceTooltip.module.scss
#	frontend/src/pages/TraceDetailsV3/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx
#	frontend/src/pages/TraceDetailsV3/index.tsx
#	frontend/src/pages/TraceDetailsV3/shared.module.scss
#	frontend/src/pages/TraceDetailsV3/stories/TraceDetailsV3.stories.mocks.tsx
#	frontend/src/pages/TraceDetailsV3/stories/__story_mockdata__/traceDetails.ts
2026-10-12 01:13:58 +05:30
Gaurav Tewari
a83681a40c feat(trace-details): waterfall tooltip redesign and AI span badges 2026-10-12 01:04:10 +05:30
Gaurav Tewari
349535510a Merge branch 'feat/trace-waterfall-tooltip' into feat/trace-details-tabs 2026-10-08 00:22:24 +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
a8daa1bf18 refactor(trace-details): move the page body into TraceDetailsOverview
Tabs and thread changes move to a follow-up PR.
2026-10-07 22:58:11 +05:30
Gaurav Tewari
b04381a6b0 test(trace-details): adapt zero-token header test to the summary mock 2026-10-07 22:57:18 +05:30
Gaurav Tewari
2c642d6f41 Merge remote-tracking branch 'origin/feat/trace-waterfall-tooltip' into feat/trace-details-tabs 2026-10-07 22:42:54 +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
27ada1241b refactor(trace-details): split the page into Overview and Thread tabs
The header reads the trace summary itself, so Overview no longer feeds it.

Assisted-by: Claude Opus 5.5
2026-10-07 18:32:55 +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
48 changed files with 1759 additions and 1233 deletions

View File

@@ -13691,7 +13691,7 @@ paths:
post:
deprecated: false
description: This endpoint promotes paths of JSON columns to their promoted
columns. Each path names its promotion target with its signal and context,
columns. Each path names its promotion domain with its signal and context,
e.g. traces/attribute.
operationId: PromotePaths
requestBody:
@@ -13706,7 +13706,7 @@ paths:
- fieldDataType: string
granularity: 1
type: ngrambf_v1(4, 1024, 2, 0)
path: user.name
path: body.user.name
promote: true
signal: logs
traces_attribute:

View File

@@ -146,7 +146,7 @@ export const invalidateListPromotedPaths = async (
};
/**
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion domain with its signal and context, e.g. traces/attribute.
* @summary Promote paths
*/
export const promotePaths = (

View File

@@ -1,11 +1,13 @@
import { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { TooltipSimpleProps } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import TraceTooltip from '../TraceTooltip/TraceTooltip';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: string;
tooltip: TooltipSimpleProps['title'];
icon?: ReactNode;
children: ReactNode;
}
@@ -16,12 +18,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: 180px;
}
.tokenTooltipTitle {
padding-bottom: var(--spacing-4);
}

View File

@@ -1,10 +1,18 @@
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,
getTotalInputTokens,
hasValue,
} from '../utils/aiTraceUtils';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import styles from './EntityMetadataRow.module.scss';
@@ -16,6 +24,8 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: AiTokenCounts;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -32,8 +42,11 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const totalInput = tokens && getTotalInputTokens(tokens);
const durationTooltip =
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
// Single source of duration formatting so both rows label units identically.
@@ -91,6 +104,24 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{hasValue(cost) && (
<EntityMetadataItem
tooltip="Total cost"
icon={<Landmark size={ICON_SIZE} />}
>
Cost — {formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -102,6 +133,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 '../TokenBreakdown/TokenBreakdown';
import { AiTokenCounts } from '../utils/aiTraceUtils';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: AiTokenCounts;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
return (
<div className={styles.tokenTooltip}>
<Typography.Text
size="small"
weight="normal"
className={styles.tokenTooltipTitle}
>
Usage Breakdown
</Typography.Text>
<TokenBreakdown
input={input}
output={output}
cacheRead={cacheRead}
cacheWrite={cacheWrite}
reasoning={reasoning}
/>
</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 '../utils/aiTraceUtils';
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,26 @@ 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: {
'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,000 → 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/aiTraceUtils';
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,10 @@
import { AiSpanKind } from '../utils/aiTraceUtils';
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,24 @@
}
.popover {
composes: content from '../TraceTooltip/TraceTooltip.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';
min-width: 223px;
max-width: 360px;
}
.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 '../utils/aiTraceUtils';
import SpanUsageBreakdown from './SpanUsageBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
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,54 @@ 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="normal"
style={{ color }}
>
{spanName}
</Typography.Text>
{ai?.usage && (
<Typography.Text size="small" weight="normal">
Usage Breakdown
</Typography.Text>
)}
</div>
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
<TraceTooltipSection>
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
<TraceTooltipRow label="status" value={hasError ? 'error' : 'ok'} />
<TraceTooltipRow
label="start"
value={`${toFixed(relativeStartMs, 2)} ms`}
/>
<TraceTooltipRow
label="duration"
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
/>
</TraceTooltipSection>
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
<TraceTooltipSection>
{previewRows.map((row) => (
<div
<TraceTooltipRow
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>
</TraceTooltipSection>
)}
</div>
);
@@ -137,6 +165,7 @@ export function SpanHoverCard({
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
ai: getAIUsageDetails(span),
},
};
}, [

View File

@@ -0,0 +1,38 @@
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
interface SpanUsageBreakdownProps {
usage: SpanAiUsage;
}
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
const {
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
cost,
} = usage;
return (
<>
<TokenBreakdown
input={inputTokens}
output={outputTokens}
cacheRead={cacheReadTokens}
cacheWrite={cacheCreationTokens}
reasoning={reasoningTokens}
/>
{hasValue(cost) && (
<TraceTooltipSection>
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
</TraceTooltipSection>
)}
</>
);
}
export default SpanUsageBreakdown;

View File

@@ -0,0 +1,81 @@
import {
AiTokenCounts,
formatTokens,
getTokenLabel,
getTotalInputTokens,
} from '../utils/aiTraceUtils';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
type TokenBreakdownProps = AiTokenCounts;
function TokenBreakdown({
input,
output,
cacheRead,
cacheWrite,
reasoning,
}: TokenBreakdownProps): JSX.Element {
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
return (
<>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Total Input', totalInput)}
value={formatTokens(totalInput)}
isHeading
/>
<TraceTooltipRow
label={getTokenLabel('Cache Read', cacheRead)}
value={formatTokens(cacheRead)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Cache Write', cacheWrite)}
value={formatTokens(cacheWrite)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Input', input)}
value={formatTokens(input)}
isNested
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Reasoning', reasoning)}
value={formatTokens(reasoning)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Output', output)}
value={formatTokens(output)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label="Total Token Usage"
value={formatTokens(totalInput + (output ?? 0))}
isHeading
/>
</TraceTooltipSection>
</>
);
}
TokenBreakdown.defaultProps = {
input: undefined,
output: undefined,
cacheRead: undefined,
cacheWrite: undefined,
reasoning: undefined,
};
export default TokenBreakdown;

View File

@@ -195,6 +195,8 @@ function TraceDetailsHeader({
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceSummary.rootSpanStatusCode}
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />

View File

@@ -1,6 +1,7 @@
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';
@@ -176,9 +177,19 @@ describe('TraceDetailsHeader – trace metadata row', () => {
hasMissingSpans: false,
totalSpansCount: 42,
totalErrorSpansCount: 0,
ai: {
tokens: {
input: 12040,
output: 3110,
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
},
totalCost: 0.0421,
},
};
const mockSummary = (data?: typeof traceMetadata): void => {
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
};
@@ -201,6 +212,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 input and output are both zero', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
},
} as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('shows zero for a missing input', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: undefined },
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
});
it('shows zero AI tokens when input and output are both missing', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: {
...traceMetadata.ai.tokens,
input: undefined,
output: undefined,
},
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).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 });
mockSummary(traceMetadata);

View File

@@ -0,0 +1,512 @@
/* 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 { 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 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 './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} />;
}
function TraceDetailsOverview(): 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 [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);
setIsFilterActive(isActive);
},
[],
);
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 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 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 (
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={
!!traceData?.payload?.spans?.length && !showNoData
}
/>
{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>
);
}
export default TraceDetailsOverview;

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 TraceTooltipSurface from '../TraceTooltip/TraceTooltipSurface';
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
<TraceTooltipSurface
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>,
</TraceTooltipSurface>,
document.body,
)
: null;

View File

@@ -0,0 +1,71 @@
.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);
}
.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);
/* Above the docked span details FloatingPanel (z-index 999). */
--tooltip-z-index: 1000;
backdrop-filter: blur(20px);
}
.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;
}
.section:first-child {
padding-top: 0;
border-top: none;
}
.section:last-child {
padding-bottom: 0;
}
.row {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
column-gap: var(--spacing-2);
min-width: 0;
}
.nestedRow {
padding-left: var(--spacing-6);
}
.rowLabel {
min-width: 0;
overflow-wrap: anywhere;
}
.rowValue {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,21 @@
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import styles from './TraceTooltip.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

@@ -0,0 +1,42 @@
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './TraceTooltip.module.scss';
interface TraceTooltipRowProps {
label: string;
value?: string;
isNested?: boolean;
isHeading?: boolean;
testId?: string;
}
function TraceTooltipRow({
label,
value,
isNested,
isHeading,
testId,
}: TraceTooltipRowProps): JSX.Element {
return (
<div
className={cx(styles.row, isNested && styles.nestedRow)}
data-testid={testId}
>
<Typography.Text
size="small"
color={isHeading ? undefined : 'muted'}
className={styles.rowLabel}
>
{label}
</Typography.Text>
{value !== undefined && (
<Typography.Text size="small" className={styles.rowValue}>
{value}
</Typography.Text>
)}
</div>
);
}
export default TraceTooltipRow;

View File

@@ -0,0 +1,15 @@
import { ReactNode } from 'react';
import styles from './TraceTooltip.module.scss';
interface TraceTooltipSectionProps {
children: ReactNode;
}
function TraceTooltipSection({
children,
}: TraceTooltipSectionProps): JSX.Element {
return <div className={styles.section}>{children}</div>;
}
export default TraceTooltipSection;

View File

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

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 '../../../TraceTooltip/TraceTooltip.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/aiTraceUtils';
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

@@ -1,512 +1,7 @@
/* 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 { 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 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';
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 [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);
setIsFilterActive(isActive);
},
[],
);
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 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 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 (
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={
!!traceData?.payload?.spans?.length && !showNoData
}
/>
{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>
);
return <TraceDetailsOverview />;
}
export default TraceDetailsV3;

View File

@@ -0,0 +1,11 @@
.tooltipBody {
display: flex;
flex-direction: column;
letter-spacing: var(--paragraph-small-400-letter-spacing);
color: var(--l1-foreground-hover);
}
.tooltipTitle {
font-family: 'SF Mono', 'Geist Mono', monospace;
word-break: break-all;
}

View File

@@ -45,6 +45,8 @@ import {
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
TRACE_SCENARIOS,
type TraceScenario,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -96,6 +98,13 @@ const spanLogScopeOf = (body: QueryRangeRequestV5): SpanLogScope => {
export const traceDetailsMocks = defineStoryMocks({
controls: {
scenario: choiceControl<TraceScenario>('Scenario', {
group: TRACE,
description:
'The spans the trace is built from: a checkout, one span per HTTP status badge, or an agent run with LLM, tool and agent spans and the AI totals in the header.',
options: TRACE_SCENARIOS,
value: 'checkout',
}),
spans: countControl('Spans', {
group: TRACE,
description:
@@ -137,6 +146,7 @@ export const traceDetailsMocks = defineStoryMocks({
},
handlers: (values, response) => {
const trace = {
scenario: values.scenario,
spans: values.spans,
errors: values.errors,
traceStart: traceStartOf(),
@@ -260,7 +270,7 @@ export const traceDetailsMocks = defineStoryMocks({
},
config: (values) => ({
route: values.spanPanel
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.spans))}`
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
: `/trace/${STORY_TRACE_ID}`,
}),
effect: (values) => {

View File

@@ -6,6 +6,11 @@ import { fireEvent, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
focusSpanIndex,
scenarioSpanCount,
spanIdAt,
} from './__story_mockdata__/traceDetails';
import { traceDetailsMocks } from './TraceDetailsV3.stories.mocks';
import TraceDetailsV3 from '../index';
@@ -86,6 +91,40 @@ export const BottomStripLoading: Story = {
/** The waterfall renders once the trace resolves, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* One span per HTTP status badge the waterfall draws: informational, success,
* redirect, client and server errors, and a span with no status code and so no
* badge.
*/
export const StatusCodes: Story = {
args: {
scenario: 'status-codes',
spans: scenarioSpanCount('status-codes'),
spanPanel: false,
},
};
const AI_SPANS = scenarioSpanCount('ai');
/**
* An agent run: LLM, tool and agent badges with sparkle icons in the waterfall,
* token and cost totals in the header, the span panel on an LLM call with its
* tokens and cost, and that span's hover card open on its usage breakdown.
*/
export const AiTrace: Story = {
args: { scenario: 'ai', spans: AI_SPANS },
play: async ({ canvasElement }): Promise<void> => {
const row = await within(canvasElement).findByTestId(
`cell-0-${spanIdAt(focusSpanIndex('ai', AI_SPANS))}`,
undefined,
untilLoaded,
);
await fireEvent.mouseOver(row);
await screen.findByText('Usage Breakdown', undefined, untilLoaded);
},
};
/**
* Turns on Highlight errors, which writes `has_error = true` into the filter and
* expands the bar to show it.

View File

@@ -23,6 +23,10 @@ interface SpanTemplate {
name: string;
kind: string;
failing?: boolean;
/** The span the panel opens on when nothing fails. */
focus?: boolean;
/** `response_status_code`; failing spans default to 503, the rest to 200. */
statusCode?: string;
attributes: Record<string, string | number | boolean>;
}
@@ -123,6 +127,145 @@ const SPAN_TEMPLATES: SpanTemplate[] = [
},
];
/** One span per badge colour, plus a span with no status code and so no badge. */
const STATUS_CODE_TEMPLATES: SpanTemplate[] = [
['200', 'GET /status/ok'],
['101', 'GET /ws/upgrade'],
['201', 'POST /status/created'],
['204', 'DELETE /status/no-content'],
['301', 'GET /status/moved'],
['304', 'GET /status/not-modified'],
['400', 'POST /status/bad-request'],
['401', 'GET /status/unauthorized'],
['404', 'GET /status/not-found'],
['429', 'GET /status/rate-limited'],
['500', 'GET /status/internal-error'],
['502', 'GET /status/bad-gateway'],
['503', 'GET /status/unavailable'],
['504', 'GET /status/timeout'],
['', 'internal.compute'],
].map(
([statusCode, name]): SpanTemplate => ({
service: 'status-api',
name,
kind: statusCode ? 'Server' : 'Internal',
failing: Number(statusCode) >= 500,
statusCode,
attributes: statusCode
? {
'http.method': name.split(' ')[0],
'http.status_code': Number(statusCode),
}
: {},
}),
);
/**
* An agent run: agent, LLM and tool spans, each badged by kind. The LLM spans
* cover cache counted inside input (OpenAI), cache counted beside it
* (Anthropic), reasoning, and an embedding with input only.
*/
const AI_TEMPLATES: SpanTemplate[] = [
{
service: 'support-agent',
name: 'invoke_agent support-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'support-agent' },
},
{
service: 'support-agent',
name: 'chat gpt-4o',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'gpt-4o-2024-08-06',
'gen_ai.usage.input_tokens': 1840,
'gen_ai.usage.output_tokens': 212,
'gen_ai.usage.cache_read.input_tokens': 1024,
'gen_ai.usage.cache_creation.input_tokens': 0,
'signoz.gen_ai.usage.tokens.cost': 0.00566,
},
},
{
service: 'support-agent',
name: 'execute_tool search_docs',
kind: 'Internal',
attributes: { 'gen_ai.tool.name': 'search_docs' },
},
{
service: 'support-agent',
name: 'chat claude-sonnet-4',
kind: 'Client',
focus: true,
attributes: {
'gen_ai.request.model': 'claude-sonnet-4-20250514',
'gen_ai.usage.input_tokens': 86,
'gen_ai.usage.output_tokens': 640,
'gen_ai.usage.cache_read.input_tokens': 4200,
'gen_ai.usage.cache_creation.input_tokens': 1300,
'signoz.gen_ai.usage.tokens.cost': 0.01853,
},
},
{
service: 'docs-api',
name: 'GET /docs/search',
kind: 'Server',
attributes: {
'http.method': 'GET',
'http.route': '/docs/search',
'http.status_code': 200,
},
},
{
service: 'support-agent',
name: 'execute_tool fetch_ticket',
kind: 'Internal',
failing: true,
attributes: { 'gen_ai.tool.name': 'fetch_ticket' },
},
{
service: 'support-agent',
name: 'invoke_agent triage-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'triage-agent' },
},
{
service: 'support-agent',
name: 'embeddings text-embedding-3-small',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'text-embedding-3-small',
'gen_ai.usage.input_tokens': 312,
'signoz.gen_ai.usage.tokens.cost': 0.00000624,
},
},
{
service: 'support-agent',
name: 'chat o3-mini',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'o3-mini-2025-01-31',
'gen_ai.usage.input_tokens': 920,
'gen_ai.usage.output_tokens': 1480,
'gen_ai.usage.reasoning.output_tokens': 1152,
'signoz.gen_ai.usage.tokens.cost': 0.007524,
},
},
];
export const TRACE_SCENARIOS = ['checkout', 'status-codes', 'ai'] as const;
export type TraceScenario = (typeof TRACE_SCENARIOS)[number];
const SCENARIO_TEMPLATES: Record<TraceScenario, SpanTemplate[]> = {
checkout: SPAN_TEMPLATES,
'status-codes': STATUS_CODE_TEMPLATES,
ai: AI_TEMPLATES,
};
/** Spans it takes for the scenario to show every template once. */
export const scenarioSpanCount = (scenario: TraceScenario): number =>
SCENARIO_TEMPLATES[scenario].length;
const TRACE_SERVICES = [
...new Set(SPAN_TEMPLATES.map(({ service }) => service)),
];
@@ -146,6 +289,7 @@ interface BuiltSpan {
}
interface TraceOptions {
scenario: TraceScenario;
spans: number;
errors: boolean;
/** Epoch milliseconds the root span starts at. */
@@ -156,24 +300,33 @@ export const spanIdAt = (index: number): string =>
`b17e${index.toString(16).padStart(12, '0')}`;
/**
* The span the story opens the panel on: the first failing one, so its events
* and error styling are on screen, and the root when nothing fails.
* The span the story opens the panel on: the scenario's focus span, else the
* first failing one, so its events and error styling are on screen, and the
* root when neither is in the trace.
*/
export const focusSpanIndex = (spans: number): number => {
const failing = SPAN_TEMPLATES.findIndex(({ failing }) => failing);
export const focusSpanIndex = (
scenario: TraceScenario,
spans: number,
): number => {
const templates = SCENARIO_TEMPLATES[scenario];
const focus = templates.findIndex((template) => template.focus);
const failing = templates.findIndex((template) => template.failing);
const index = focus >= 0 ? focus : failing;
return failing >= 0 && failing < spans ? failing : 0;
return index >= 0 && index < spans ? index : 0;
};
const buildSpans = ({
scenario,
spans,
errors,
traceStart,
}: TraceOptions): BuiltSpan[] => {
const built: BuiltSpan[] = [];
const templates = SCENARIO_TEMPLATES[scenario];
for (let index = 0; index < spans; index += 1) {
const template = SPAN_TEMPLATES[index % SPAN_TEMPLATES.length];
const template = templates[index % templates.length];
const parent = index === 0 ? undefined : built[parentIndexOf(index)];
// Children fit inside their parent's window, second child after the first,
// so no span outlives its parent and no two siblings overlap.
@@ -257,7 +410,8 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
status_code: span.hasError ? 2 : 1,
status_code_string: span.hasError ? 'Error' : 'Ok',
status_message: span.hasError ? 'upstream unavailable' : '',
response_status_code: span.hasError ? '503' : '200',
response_status_code:
span.template.statusCode ?? (span.hasError ? '503' : '200'),
has_children: span.hasChildren,
sub_tree_node_count: span.subTreeNodeCount,
level: span.level,
@@ -304,6 +458,36 @@ export const traceWaterfallResponse = (
};
};
const numberAttribute = (span: BuiltSpan, key: string): number =>
Number(span.template.attributes[key] ?? 0);
/** The trace's AI totals, summed over its spans the way the backend does. */
const traceAiSummary = (
spans: BuiltSpan[],
): GetTraceSummary200['data']['ai'] => {
const aiSpans = spans.filter(({ template }) =>
Object.keys(template.attributes).some((key) => key.includes('gen_ai.')),
);
if (aiSpans.length === 0) {
return undefined;
}
const sum = (key: string): number =>
aiSpans.reduce((total, span) => total + numberAttribute(span, key), 0);
return {
tokens: {
input: sum('gen_ai.usage.input_tokens'),
output: sum('gen_ai.usage.output_tokens'),
cacheRead: sum('gen_ai.usage.cache_read.input_tokens'),
cacheWrite: sum('gen_ai.usage.cache_creation.input_tokens'),
reasoning: sum('gen_ai.usage.reasoning.output_tokens'),
},
totalCost: sum('signoz.gen_ai.usage.tokens.cost'),
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
@@ -321,6 +505,7 @@ export const traceSummaryResponse = (
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
ai: traceAiSummary(spans),
},
};
};
@@ -472,7 +657,7 @@ export const spanLogRows = (
): RawRow[] => {
const step = ROOT_DURATION_MS / Math.max(count, 1);
const spans = buildSpans(options);
const focus = spans[focusSpanIndex(options.spans)];
const focus = spans[focusSpanIndex(options.scenario, options.spans)];
return Array.from({ length: count }, (_, index) => {
const at = options.traceStart + SCOPE_OFFSET_MS[scope] + index * step;

View File

@@ -0,0 +1,51 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import {
AiSpanKind,
getAiSpanKind,
getTotalInputTokens,
isAiSpan,
} from '../aiTraceUtils';
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);
});
});
describe('getTotalInputTokens', () => {
it.each([
// Cache reported inside input (OpenAI): input already includes it.
[{ input: 1000, cacheRead: 600, cacheWrite: 100 }, 1000],
// Cache reported separately (Anthropic): add it.
[{ input: 50, cacheRead: 600, cacheWrite: 100 }, 750],
[{ input: 71, cacheRead: 0, cacheWrite: 0 }, 71],
[{ input: 10 }, 10],
[{ cacheRead: 600 }, 600],
])('%p → %p', (tokens, expected) => {
expect(getTotalInputTokens(tokens)).toBe(expected);
});
});

View File

@@ -0,0 +1,176 @@
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',
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?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
reasoning?: number;
}
export function hasValue(value: number | null | undefined): value is number {
return value !== undefined && value !== null;
}
/**
* Input including cache read/write, counted once. Providers that report cache
* inside `input` (OpenAI) have `input >= cache`; those that report it
* separately (Anthropic) usually have `input < cache`.
*/
export function getTotalInputTokens({
input,
cacheRead,
cacheWrite,
}: AiTokenCounts): number {
const inputValue = input ?? 0;
const cache = (cacheRead ?? 0) + (cacheWrite ?? 0);
return inputValue < cache ? inputValue + cache : inputValue;
}
/** `Input Token` for a count of 1, `Input Tokens` otherwise. */
export function getTokenLabel(
prefix: string,
count: number | null | undefined,
): string {
return `${prefix} ${count === 1 ? 'Token' : 'Tokens'}`;
}
/** Missing values render as `0`. */
export function formatTokens(value: number | null | undefined): string {
return hasValue(value) ? value.toLocaleString('en-US') : '0';
}
export function formatCost(value: number): string {
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
}
export interface SpanAiUsage {
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 = {
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 {
input: usage.inputTokens,
output: usage.outputTokens,
cacheRead: usage.cacheReadTokens,
cacheWrite: usage.cacheCreationTokens,
reasoning: usage.reasoningTokens,
};
}

View File

@@ -14,7 +14,7 @@ func (provider *provider) addPromoteRoutes(router *mux.Router) error {
ID: "PromotePaths",
Tags: []string{"promote"},
Summary: "Promote paths",
Description: "This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.",
Description: "This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion domain with its signal and context, e.g. traces/attribute.",
Request: new([]*promotetypes.PromotePath),
RequestContentType: "application/json",
RequestExamples: []handler.OpenAPIExample{
@@ -25,7 +25,7 @@ func (provider *provider) addPromoteRoutes(router *mux.Router) error {
{
"signal": "logs",
"context": "body",
"path": "user.name",
"path": "body.user.name",
"promote": true,
"indexes": []map[string]any{
{"fieldDataType": "string", "type": "ngrambf_v1(4, 1024, 2, 0)", "granularity": 1},

View File

@@ -32,10 +32,6 @@ func (h *handler) PromotePaths(w http.ResponseWriter, r *http.Request) {
render.Error(w, err)
return
}
if err := promotetypes.ValidatePromotePaths(req); err != nil {
render.Error(w, err)
return
}
err = h.module.PromotePaths(r.Context(), req...)
if err != nil {

View File

@@ -58,12 +58,16 @@ func (m *module) listPromotedPaths(ctx context.Context, target promotetypes.Targ
response = append(response, promotetypes.PromotePath{
Signal: target.Entry.Signal.StringValue(),
Context: target.Entry.FieldContext.StringValue(),
Path: path,
Path: target.RequiredPathPrefix + path,
Promote: true,
})
}
indexes, err := m.metadataStore.ListJSONIndexes(ctx, target.JSONIndexLookup())
if !target.IndexesSupported {
return response, nil
}
indexes, err := m.metadataStore.ListLogsJSONIndexes(ctx)
if err != nil {
return nil, err
}
@@ -81,7 +85,7 @@ func (m *module) listPromotedPaths(ctx context.Context, target promotetypes.Targ
}
for i := range response {
fullPath := target.PromotedColumnPrefix() + response[i].Path
fullPath := target.PromotedColumnPrefix() + strings.TrimPrefix(response[i].Path, target.RequiredPathPrefix)
if indexes, ok := aggr[fullPath]; ok {
response[i].Indexes = indexes
delete(aggr, fullPath)
@@ -91,6 +95,7 @@ func (m *module) listPromotedPaths(ctx context.Context, target promotetypes.Targ
for fullPath, indexes := range aggr {
path := strings.TrimPrefix(fullPath, target.BaseColumnPrefix())
path = strings.TrimPrefix(path, target.PromotedColumnPrefix())
path = target.RequiredPathPrefix + path
response = append(response, promotetypes.PromotePath{
Signal: target.Entry.Signal.StringValue(),
Context: target.Entry.FieldContext.StringValue(),
@@ -102,6 +107,10 @@ func (m *module) listPromotedPaths(ctx context.Context, target promotetypes.Targ
}
func (m *module) PromotePaths(ctx context.Context, paths ...*promotetypes.PromotePath) error {
if len(paths) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "paths cannot be empty")
}
byTarget := map[promotetypes.Target][]*promotetypes.PromotePath{}
targets := []promotetypes.Target{}
for _, path := range paths {
@@ -109,6 +118,9 @@ func (m *module) PromotePaths(ctx context.Context, paths ...*promotetypes.Promot
if err != nil {
return err
}
if err := path.ValidateAndSetDefaults(target); err != nil {
return err
}
if _, ok := byTarget[target]; !ok {
targets = append(targets, target)
}
@@ -182,7 +194,7 @@ func (m *module) promotePaths(ctx context.Context, target promotetypes.Target, p
}
indexes = append(indexes, schemamigrator.Index{
Name: schemamigrator.JSONSubColumnIndexName(parentColumn, it.Path, index.JSONDataType.StringValue(), typeIndex),
Expression: target.IndexExpression(parentColumn, it.Path, index.JSONDataType.StringValue()),
Expression: schemamigrator.JSONSubColumnIndexExpr(parentColumn, it.Path, index.JSONDataType.StringValue()),
Type: index.Type,
Granularity: index.Granularity,
})

View File

@@ -36,10 +36,10 @@ func TestPromotePaths(t *testing.T) {
wantPromoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalTraces: {"http.method": true, "span.operation": true}},
},
{
name: "MixedContexts_RecordedPerTarget",
name: "MixedDomains_RecordedPerTarget",
paths: []*promotetypes.PromotePath{
{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true},
{Signal: "logs", Context: "body", Path: "user.name", Promote: true},
{Signal: "logs", Context: "body", Path: "body.user.name", Promote: true},
},
wantPromoted: map[telemetrytypes.Signal]map[string]bool{
telemetrytypes.SignalTraces: {"http.method": true},
@@ -51,19 +51,41 @@ func TestPromotePaths(t *testing.T) {
paths: []*promotetypes.PromotePath{{Signal: "traces", Context: "attribute", Path: "http.method"}},
wantPromoted: map[telemetrytypes.Signal]map[string]bool{},
},
{
name: "InvalidIndexTypeInLaterDomain_NothingRecorded",
paths: []*promotetypes.PromotePath{
{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true},
{Signal: "logs", Context: "body", Path: "body.user.name", Promote: true, Indexes: []promotetypes.WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "unsupported", Granularity: 1}}},
},
wantErr: true,
},
{
name: "InvalidSignal_Rejected",
paths: []*promotetypes.PromotePath{{Signal: "events", Context: "attribute", Path: "http.method", Promote: true}},
wantErr: true,
},
{
name: "UnsupportedTarget_Rejected",
name: "UnsupportedDomain_Rejected",
paths: []*promotetypes.PromotePath{{Signal: "metrics", Context: "attribute", Path: "http.method", Promote: true}},
wantErr: true,
},
{
name: "PromotesBareBodyPath_AsIs",
paths: []*promotetypes.PromotePath{{Signal: "logs", Context: "body", Path: "user.name", Promote: true}},
name: "ColumnPrefixedPath_Rejected",
paths: []*promotetypes.PromotePath{{Signal: "traces", Context: "attribute", Path: "attributes.http.method", Promote: true}},
wantErr: true,
},
{
name: "EmptyPath_Rejected",
paths: []*promotetypes.PromotePath{{Signal: "traces", Context: "attribute", Path: "", Promote: true}},
wantErr: true,
},
{
name: "EmptyRequest_Rejected",
wantErr: true,
},
{
name: "PromotesBodyPath_PrefixStripped",
paths: []*promotetypes.PromotePath{{Signal: "logs", Context: "body", Path: "body.user.name", Promote: true}},
wantPromoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalLogs: {"user.name": true}},
},
}
@@ -101,92 +123,43 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
wantDDLColumn string
}{
{
name: "LogsNewPromotion_IndexesPromotedColumn",
name: "NewPromotion_IndexesPromotedColumn",
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Path: "body.user.name",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
wantDDLColumn: "dynamicElement(body_promoted.user.name",
},
{
name: "LogsAlreadyPromoted_IndexesPromotedColumn",
name: "AlreadyPromoted_IndexesPromotedColumn",
promoted: map[string]bool{"user.name": true},
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Path: "body.user.name",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
wantDDLColumn: "dynamicElement(body_promoted.user.name",
},
{
name: "LogsUnpromotedPath_IndexesBaseColumn",
name: "UnpromotedPath_IndexesBaseColumn",
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Path: "body.user.name",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
wantDDLColumn: "dynamicElement(body_v2.user.name",
},
{
name: "TracesNewPromotion_IndexesPromotedColumn",
path: &promotetypes.PromotePath{
Signal: "traces",
Context: "attribute",
Path: "http.method",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`attributes_promoted.http.method_String_ngrambf_v1` attributes_promoted.`http.method`::String",
},
{
name: "TracesUnpromotedPath_IndexesBaseColumn",
path: &promotetypes.PromotePath{
Signal: "traces",
Context: "attribute",
Path: "http.method",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`attributes.http.method_String_ngrambf_v1` attributes.`http.method`::String",
},
{
name: "BloomFilterIndex_FullTypeInDDL",
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(0.01)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`body_v2.user.name_String_bloom_filter` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE bloom_filter(0.01)",
},
{
name: "SetIndex_FullTypeInDDL",
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "set(100)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`body_v2.user.name_String_set` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE set(100)",
},
}
for _, testCase := range testCases {
@@ -216,18 +189,18 @@ func TestListPromotedPaths(t *testing.T) {
wantPaths []promotetypes.PromotePath
}{
{
name: "PromotedPaths_ListedPerTarget",
name: "PromotedPaths_ListedPerDomain",
promoted: map[telemetrytypes.Signal]map[string]bool{
telemetrytypes.SignalLogs: {"user.name": true},
telemetrytypes.SignalTraces: {"http.method": true},
},
wantPaths: []promotetypes.PromotePath{
{Signal: "logs", Context: "body", Path: "user.name", Promote: true},
{Signal: "logs", Context: "body", Path: "body.user.name", Promote: true},
{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true},
},
},
{
name: "SignalFilter_SkipsOtherContexts",
name: "SignalFilter_SkipsOtherDomains",
filters: promotetypes.ListPromotedPathsFilters{Signal: "traces"},
promoted: map[telemetrytypes.Signal]map[string]bool{
telemetrytypes.SignalLogs: {"user.name": true},
@@ -236,22 +209,22 @@ func TestListPromotedPaths(t *testing.T) {
wantPaths: []promotetypes.PromotePath{{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true}},
},
{
name: "ContextFilter_SkipsOtherContexts",
name: "ContextFilter_SkipsOtherDomains",
filters: promotetypes.ListPromotedPathsFilters{Context: "body"},
promoted: map[telemetrytypes.Signal]map[string]bool{
telemetrytypes.SignalLogs: {"user.name": true},
telemetrytypes.SignalTraces: {"http.method": true},
},
wantPaths: []promotetypes.PromotePath{{Signal: "logs", Context: "body", Path: "user.name", Promote: true}},
wantPaths: []promotetypes.PromotePath{{Signal: "logs", Context: "body", Path: "body.user.name", Promote: true}},
},
{
name: "ContextAliasFilter_MatchesTarget",
name: "ContextAliasFilter_MatchesDomain",
filters: promotetypes.ListPromotedPathsFilters{Signal: "traces", Context: "tag"},
promoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalTraces: {"http.method": true}},
wantPaths: []promotetypes.PromotePath{{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true}},
},
{
name: "LogsIndexes_MergedWithLogsPaths",
name: "IndexedPaths_MergedForSupportingDomains",
promoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalLogs: {"user.name": true}},
indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{
@@ -275,7 +248,7 @@ func TestListPromotedPaths(t *testing.T) {
{
Signal: "logs",
Context: "body",
Path: "user.name",
Path: "body.user.name",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
@@ -284,7 +257,7 @@ func TestListPromotedPaths(t *testing.T) {
{
Signal: "logs",
Context: "body",
Path: "request.duration",
Path: "body.request.duration",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeFloat64, Type: "minmax", Granularity: 1},
},
@@ -309,7 +282,7 @@ func TestListPromotedPaths(t *testing.T) {
{
Signal: "logs",
Context: "body",
Path: "request.duration",
Path: "body.request.duration",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeFloat64, Type: "minmax", Granularity: 1},
},
@@ -334,7 +307,7 @@ func TestListPromotedPaths(t *testing.T) {
{
Signal: "logs",
Context: "body",
Path: "user.name",
Path: "body.user.name",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
@@ -342,47 +315,6 @@ func TestListPromotedPaths(t *testing.T) {
},
},
},
{
name: "TracesIndexes_MergedWithTracesPaths",
promoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalTraces: {"http.method": true}},
indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{
Name: "http.method",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
BaseColumn: "attributes_promoted.",
IndexType: "ngrambf_v1(4, 1024, 2, 0)",
Granularity: 1,
},
{
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
BaseColumn: "attributes.",
IndexType: "minmax",
Granularity: 1,
},
},
wantPaths: []promotetypes.PromotePath{
{
Signal: "traces",
Context: "attribute",
Path: "http.method",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
{
Signal: "traces",
Context: "attribute",
Path: "http.status_code",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeFloat64, Type: "minmax", Granularity: 1},
},
},
},
},
}
for _, testCase := range testCases {
@@ -396,14 +328,14 @@ func TestListPromotedPaths(t *testing.T) {
require.NoError(t, err)
require.Len(t, paths, len(testCase.wantPaths))
byContextPath := map[string]promotetypes.PromotePath{}
byDomainPath := map[string]promotetypes.PromotePath{}
for _, path := range paths {
byContextPath[path.Signal+"/"+path.Context+"/"+path.Path] = path
byDomainPath[path.Signal+"/"+path.Context+"/"+path.Path] = path
}
for _, want := range testCase.wantPaths {
key := want.Signal + "/" + want.Context + "/" + want.Path
require.Contains(t, byContextPath, key)
assert.Equal(t, want, byContextPath[key])
require.Contains(t, byDomainPath, key)
assert.Equal(t, want, byDomainPath[key])
}
})
}

View File

@@ -5,12 +5,12 @@ import (
"fmt"
"log/slog"
"reflect"
"regexp"
"strings"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/constants"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
@@ -36,24 +36,6 @@ var (
var logsBodyPromotedEntry = promotetypes.NewLogsBodyTarget().Entry
var logsBodyIndexLookup = promotetypes.NewLogsBodyTarget().JSONIndexLookup()
// ClickHouse stores a `col.path::Type` skip index expression as CAST(col.path, 'Type').
var simpleJSONSubColumnIndexExprRe = regexp.MustCompile(`^CAST\((?P<expr>[^()]+), '(?P<type>.+)'\)$`)
// unfoldJSONSubColumnIndexExpr accepts both the folded (lower/assumeNotNull)
// and the bare type-cast expression forms.
func unfoldJSONSubColumnIndexExpr(expr string) (string, string, error) {
if columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(expr); err == nil {
return columnExpr, columnType, nil
}
matches := simpleJSONSubColumnIndexExprRe.FindStringSubmatch(expr)
if matches == nil {
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid expression: %s", expr)
}
return matches[1], matches[2], nil
}
// enrichJSONKeys enriches body-context keys with promoted path info, indexes,
// and JSON access plans. parentTypeCache contains parent array types (ArrayJSON/ArrayDynamic)
// pre-fetched in the main UNION query.
@@ -141,7 +123,7 @@ func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...st
}
// list indexes for the paths
indexes, err := t.ListJSONIndexes(ctx, logsBodyIndexLookup, filteredPaths...)
indexes, err := t.ListLogsJSONIndexes(ctx, filteredPaths...)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to list JSON path indexes")
}
@@ -155,16 +137,16 @@ func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...st
return fieldPathToIndexes, nil
}
func buildListJSONIndexesQuery(cluster string, lookup telemetrytypes.JSONIndexLookup, filters ...string) (string, []any) {
func buildListLogsJSONIndexesQuery(cluster string, filters ...string) (string, []any) {
sb := sqlbuilder.Select(
"name", "type_full", "expr", "granularity",
).From(fmt.Sprintf("clusterAllReplicas('%s', %s)", cluster, SkipIndexTableName))
sb.Where(sb.Equal("database", lookup.DBName))
sb.Where(sb.Equal("table", lookup.LocalTableName))
sb.Where(sb.Equal("database", logstelemetryschema.DBName))
sb.Where(sb.Equal("table", logstelemetryschema.LogsV2LocalTableName))
sb.Where(sb.Or(
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(lookup.BaseColumnPrefix))),
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(lookup.PromotedColumnPrefix))),
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyV2ColumnPrefix))),
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyPromotedColumnPrefix))),
))
filterExprs := []string{}
@@ -177,9 +159,9 @@ func buildListJSONIndexesQuery(cluster string, lookup telemetrytypes.JSONIndexLo
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemetrytypes.JSONIndexLookup, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
ctx = withTelemetryContext(ctx, lookup.Signal, "ListJSONIndexes")
query, args := buildListJSONIndexesQuery(t.telemetrystore.Cluster(), lookup, filters...)
func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
ctx = withTelemetryContext(ctx, telemetrytypes.SignalLogs, "ListLogsJSONIndexes")
query, args := buildListLogsJSONIndexesQuery(t.telemetrystore.Cluster(), filters...)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to load string indexed columns")
@@ -196,7 +178,7 @@ func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemet
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to scan string indexed column")
}
columnExpr, columnType, err := unfoldJSONSubColumnIndexExpr(expr)
columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(expr)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to unfold JSON sub column index expression: %s", expr)
}
@@ -210,18 +192,18 @@ func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemet
baseColumn := ""
fieldName := ""
switch {
case strings.HasPrefix(columnExpr, lookup.BaseColumnPrefix):
baseColumn = lookup.BaseColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, lookup.BaseColumnPrefix)
case strings.HasPrefix(columnExpr, lookup.PromotedColumnPrefix):
baseColumn = lookup.PromotedColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, lookup.PromotedColumnPrefix)
case strings.HasPrefix(columnExpr, logstelemetryschema.BodyV2ColumnPrefix):
baseColumn = logstelemetryschema.BodyV2ColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, logstelemetryschema.BodyV2ColumnPrefix)
case strings.HasPrefix(columnExpr, logstelemetryschema.BodyPromotedColumnPrefix):
baseColumn = logstelemetryschema.BodyPromotedColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, logstelemetryschema.BodyPromotedColumnPrefix)
}
fieldName = strings.ReplaceAll(fieldName, "`", "")
indexes = append(indexes, telemetrytypes.TelemetryFieldKeySkipIndex{
Name: fieldName,
FieldContext: lookup.FieldContext,
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: fdt,
BaseColumn: baseColumn,
IndexName: name,

View File

@@ -2,13 +2,11 @@ package telemetrymetadata
import (
"fmt"
"strings"
"testing"
"github.com/SigNoz/signoz-otel-collector/constants"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/types/promotetypes"
"github.com/stretchr/testify/require"
)
@@ -52,106 +50,10 @@ func TestBuildListLogsJSONIndexesQuery(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
query, args := buildListJSONIndexesQuery(tc.cluster, logsBodyIndexLookup, tc.filters...)
query, args := buildListLogsJSONIndexesQuery(tc.cluster, tc.filters...)
require.Equal(t, tc.expectedSQL, query)
require.Equal(t, tc.expectedArgs, args)
})
}
}
func TestUnfoldJSONSubColumnIndexExpr(t *testing.T) {
testCases := []struct {
name string
expr string
wantColumn string
wantType string
wantErr bool
}{
{
name: "LogsFoldedLower",
expr: "lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String')))",
wantColumn: "body_v2.user.name",
wantType: "String",
},
{
name: "LogsAssumeNotNullOnly",
expr: "assumeNotNull(dynamicElement(body_v2.request.duration, 'Float64'))",
wantColumn: "body_v2.request.duration",
wantType: "Float64",
},
{
name: "TracesCastDottedKey",
expr: "CAST(attributes.`http.method`, 'String')",
wantColumn: "attributes.`http.method`",
wantType: "String",
},
{
name: "TracesCastPlainKey",
expr: "CAST(attributes_promoted.status, 'Int64')",
wantColumn: "attributes_promoted.status",
wantType: "Int64",
},
{
name: "UnrecognizedForm",
expr: "toString(attributes.status)",
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
columnExpr, columnType, err := unfoldJSONSubColumnIndexExpr(tc.expr)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.wantColumn, columnExpr)
require.Equal(t, tc.wantType, columnType)
})
}
}
// ClickHouse stores a generated col.`path`::Type trace index expression as
// CAST(col.`path`, 'Type'), dropping quoting an identifier does not need; the
// listing must parse that stored form back into the original path.
func TestUnfoldJSONSubColumnIndexExprTracesRoundTrip(t *testing.T) {
testCases := []struct {
name string
column string
path string
jsonDataType string
wantExpr string
storedExpr string
}{
{
name: "DottedKey_BackticksPreservedInStorage",
column: "attributes",
path: "http.method",
jsonDataType: "String",
wantExpr: "attributes.`http.method`::String",
storedExpr: "CAST(attributes.`http.method`, 'String')",
},
{
name: "PlainKey_UnquotedInStorage",
column: "attributes_promoted",
path: "status",
jsonDataType: "Int64",
wantExpr: "attributes_promoted.`status`::Int64",
storedExpr: "CAST(attributes_promoted.status, 'Int64')",
},
}
target := promotetypes.NewTracesAttributesTarget()
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.wantExpr, target.IndexExpression(tc.column, tc.path, tc.jsonDataType))
columnExpr, columnType, err := unfoldJSONSubColumnIndexExpr(tc.storedExpr)
require.NoError(t, err)
require.Equal(t, tc.column+"."+tc.path, strings.ReplaceAll(columnExpr, "`", ""))
require.Equal(t, tc.jsonDataType, columnType)
})
}
}

View File

@@ -1,23 +1,20 @@
package promotetypes
import (
"fmt"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// Target identifies a promotion context.
// Target identifies a promotion domain.
type Target struct {
Entry telemetrytypes.EvolutionEntry // evolution row template; FieldName and ReleaseTime are set per write
DBName string // index DDL database
LocalTableName string // index DDL local table
BaseColumn string // column holding every path; indexes for unpromoted paths are created on it
Entry telemetrytypes.EvolutionEntry // evolution row template; FieldName and ReleaseTime are set per write
DBName string // index DDL database, used only when IndexesSupported
LocalTableName string // index DDL local table, used only when IndexesSupported
BaseColumn string // column holding every path; indexes for unpromoted paths are created on it
RequiredPathPrefix string // prefix API paths must carry, stripped before storing; empty for bare names
IndexesSupported bool
}
func (t Target) PromotedColumn() string { return t.Entry.ColumnName }
@@ -26,38 +23,18 @@ func (t Target) BaseColumnPrefix() string { return t.BaseColumn + "." }
func (t Target) PromotedColumnPrefix() string { return t.PromotedColumn() + "." }
// IndexExpression folds logs strings to lower case over assumeNotNull for
// case-insensitive LIKE searches; traces indexes are a bare type cast.
func (t Target) IndexExpression(column, path, jsonDataType string) string {
switch t.Entry.Signal {
case telemetrytypes.SignalLogs:
return schemamigrator.JSONSubColumnIndexExpr(column, path, jsonDataType)
default:
return simpleJSONSubColumnIndexExpr(column, path, jsonDataType)
}
}
func (t Target) JSONIndexLookup() telemetrytypes.JSONIndexLookup {
return telemetrytypes.JSONIndexLookup{
Signal: t.Entry.Signal,
FieldContext: t.Entry.FieldContext,
DBName: t.DBName,
LocalTableName: t.LocalTableName,
BaseColumnPrefix: t.BaseColumnPrefix(),
PromotedColumnPrefix: t.PromotedColumnPrefix(),
}
}
func NewTarget(entry telemetrytypes.EvolutionEntry, dbName, localTableName, baseColumn string) Target {
func NewTarget(entry telemetrytypes.EvolutionEntry, dbName, localTableName, baseColumn, requiredPathPrefix string, indexesSupported bool) Target {
return Target{
Entry: entry,
DBName: dbName,
LocalTableName: localTableName,
BaseColumn: baseColumn,
Entry: entry,
DBName: dbName,
LocalTableName: localTableName,
BaseColumn: baseColumn,
RequiredPathPrefix: requiredPathPrefix,
IndexesSupported: indexesSupported,
}
}
// NewLogsBodyTarget returns the logs body context (body_v2 -> body_promoted).
// NewLogsBodyTarget returns the logs body domain (body_v2 -> body_promoted).
func NewLogsBodyTarget() Target {
return NewTarget(
telemetrytypes.EvolutionEntry{
@@ -69,10 +46,12 @@ func NewLogsBodyTarget() Target {
logstelemetryschema.DBName,
logstelemetryschema.LogsV2LocalTableName,
logstelemetryschema.LogsV2BodyV2Column,
telemetrytypes.BodyJSONStringSearchPrefix,
true,
)
}
// NewTracesAttributesTarget returns the spans attributes context (attributes
// NewTracesAttributesTarget returns the spans attributes domain (attributes
// -> attributes_promoted).
func NewTracesAttributesTarget() Target {
return NewTarget(
@@ -85,6 +64,8 @@ func NewTracesAttributesTarget() Target {
tracestelemetryschema.DBName,
tracestelemetryschema.SpanIndexV3LocalTableName,
tracestelemetryschema.SpanAttributesColumn,
"",
false,
)
}
@@ -120,27 +101,3 @@ func TargetFor(signal telemetrytypes.Signal, context telemetrytypes.FieldContext
}
return Target{}, false
}
// simpleJSONSubColumnIndexExpr renders column.`path`::Type with the path
// quoted as one identifier, the form trace queries read the sub-column with;
// the cast unwraps the Nullable the sub-column access returns, which bloom
// filter indexes reject.
func simpleJSONSubColumnIndexExpr(column, path, jsonDataType string) string {
return fmt.Sprintf("%s.%s::%s", column, clickhousesql.Identifier(path), jsonDataType)
}
// reservedPathPrefix returns the target prefix path carries, if any: the base
// or promoted column prefix, or the logs body search alias, which is stripped
// from queries before metadata lookup and can never match.
func (t Target) reservedPathPrefix(path string) (string, bool) {
prefixes := []string{t.BaseColumnPrefix(), t.PromotedColumnPrefix()}
if t.Entry.Signal == telemetrytypes.SignalLogs {
prefixes = append(prefixes, telemetrytypes.BodyJSONStringSearchPrefix)
}
for _, prefix := range prefixes {
if strings.HasPrefix(path, prefix) {
return prefix, true
}
}
return "", false
}

View File

@@ -1,57 +0,0 @@
package promotetypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIndexExpression(t *testing.T) {
testCases := []struct {
name string
target Target
column string
path string
jsonDataType string
want string
}{
{
name: "LogsString_LoweredOverAssumeNotNull",
target: NewLogsBodyTarget(),
column: "body_promoted",
path: "user.name",
jsonDataType: "String",
want: "lower(assumeNotNull(dynamicElement(body_promoted.user.name, 'String')))",
},
{
name: "LogsNumber_AssumeNotNullOnly",
target: NewLogsBodyTarget(),
column: "body_v2",
path: "request.duration",
jsonDataType: "Float64",
want: "assumeNotNull(dynamicElement(body_v2.request.duration, 'Float64'))",
},
{
name: "TracesString_TypeCastOnly",
target: NewTracesAttributesTarget(),
column: "attributes_promoted",
path: "http.method",
jsonDataType: "String",
want: "attributes_promoted.`http.method`::String",
},
{
name: "TracesPathNeedingBackticks_Backticked",
target: NewTracesAttributesTarget(),
column: "attributes",
path: "user-name",
jsonDataType: "String",
want: "attributes.`user-name`::String",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, testCase.target.IndexExpression(testCase.column, testCase.path, testCase.jsonDataType))
})
}
}

View File

@@ -1,8 +1,6 @@
package promotetypes
import (
"regexp"
"strconv"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
@@ -11,20 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// maxPromotePathsPerRequest caps a promote batch so one request cannot flood
// the cluster with index DDL.
const maxPromotePathsPerRequest = 100
const (
maxIndexNGramLength = 64
maxIndexBloomFilterBytes = 1 << 20
maxIndexHashFunctions = 64
)
// indexTypeRe anchors the whole index type string, so only a whitelisted type
// with bounded numeric parameters can reach the index DDL.
var indexTypeRe = regexp.MustCompile(`^(?:minmax|set\(\s*(\d{1,7})\s*\)|bloom_filter(?:\(\s*(\d+(?:\.\d+)?|\.\d+)\s*\))?|tokenbf_v1\(\s*(\d{1,7})\s*,\s*(\d{1,2})\s*,\s*(\d{1,10})\s*\)|ngrambf_v1\(\s*(\d{1,2})\s*,\s*(\d{1,7})\s*,\s*(\d{1,2})\s*,\s*(\d{1,10})\s*\))$`)
type WrappedIndex struct {
JSONDataType telemetrytypes.JSONDataType `json:"-"`
FieldDataType telemetrytypes.FieldDataType `json:"fieldDataType"`
@@ -53,7 +37,7 @@ type ListPromotedPathsFilters struct {
}
// Validate checks the signal and context words are known; the pair need not
// name a supported target.
// name a supported domain.
func (f *ListPromotedPathsFilters) Validate() error {
if f.Signal != "" {
if _, ok := telemetrytypes.SignalFromText(f.Signal); !ok {
@@ -105,8 +89,15 @@ func (i *PromotePath) ValidateAndSetDefaults(target Target) error {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "array paths can not be promoted or indexed")
}
if prefix, ok := target.reservedPathPrefix(i.Path); ok {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "path must be a bare attribute name, without the `%s` prefix", prefix)
if strings.HasPrefix(i.Path, target.BaseColumnPrefix()) || strings.HasPrefix(i.Path, target.PromotedColumnPrefix()) {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "`%s`, `%s` don't add these prefixes to the path", target.BaseColumnPrefix(), target.PromotedColumnPrefix())
}
if target.RequiredPathPrefix != "" {
if !strings.HasPrefix(i.Path, target.RequiredPathPrefix) {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "path must start with `%s`", target.RequiredPathPrefix)
}
i.Path = strings.TrimPrefix(i.Path, target.RequiredPathPrefix)
}
isCardinal := keycheck.IsCardinal(i.Path)
@@ -114,11 +105,15 @@ func (i *PromotePath) ValidateAndSetDefaults(target Target) error {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cardinal paths can not be promoted or indexed")
}
if len(i.Indexes) > 0 && !target.IndexesSupported {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "indexes are not supported for %s %s", target.Entry.Signal.StringValue(), target.Entry.FieldContext.StringValue())
}
for idx, index := range i.Indexes {
if index.Type == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index type is required")
}
if err := validateIndexType(index.Type); err != nil {
if _, err := index.SkipIndexType(); err != nil {
return err
}
if index.Granularity <= 0 {
@@ -147,78 +142,7 @@ func (index WrappedIndex) SkipIndexType() (schemamigrator.IndexType, error) {
return schemamigrator.IndexTypeTokenBF, nil
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeMinMax)):
return schemamigrator.IndexTypeMinMax, nil
case strings.HasPrefix(index.Type, "bloom_filter"):
return "bloom_filter", nil
case strings.HasPrefix(index.Type, "set"):
return "set", nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid index type: %s", index.Type)
}
}
func ValidatePromotePaths(paths []*PromotePath) error {
if len(paths) == 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "paths cannot be empty")
}
if len(paths) > maxPromotePathsPerRequest {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot promote more than %d paths in one request", maxPromotePathsPerRequest)
}
for _, path := range paths {
if path == nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "path cannot be null")
}
target, err := path.Target()
if err != nil {
return err
}
if err := path.ValidateAndSetDefaults(target); err != nil {
return err
}
}
return nil
}
func validateIndexType(indexType string) error {
matches := indexTypeRe.FindStringSubmatch(indexType)
if matches == nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid index type: %s", indexType)
}
switch {
case indexType == "minmax" || strings.HasPrefix(indexType, "set"):
return nil
case strings.HasPrefix(indexType, "bloom_filter"):
if matches[2] == "" {
return nil
}
falsePositive, _ := strconv.ParseFloat(matches[2], 64)
if falsePositive <= 0 || falsePositive >= 1 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid false positive rate in index type: %s", indexType)
}
return nil
}
params := matches[3:6]
if strings.HasPrefix(indexType, "ngrambf_v1") {
params = matches[6:10]
}
values := make([]uint64, len(params))
for idx, param := range params {
values[idx], _ = strconv.ParseUint(param, 10, 64)
}
bloomBytes, hashes := 0, 1
if len(values) == 4 {
if values[0] < 1 || values[0] > maxIndexNGramLength {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid n-gram length in index type: %s", indexType)
}
bloomBytes, hashes = 1, 2
}
if values[bloomBytes] < 1 || values[bloomBytes] > maxIndexBloomFilterBytes {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid bloom filter size in index type: %s", indexType)
}
if values[hashes] < 1 || values[hashes] > maxIndexHashFunctions {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid hash function count in index type: %s", indexType)
}
return nil
}

View File

@@ -37,7 +37,7 @@ func TestPromotePathTarget(t *testing.T) {
wantErr: true,
},
{
name: "UnsupportedTarget_Rejected",
name: "UnsupportedDomain_Rejected",
path: &PromotePath{Signal: "metrics", Context: "attribute", Path: "http.method"},
wantErr: true,
},
@@ -67,13 +67,13 @@ func TestValidateAndSetDefaultsLogsBody(t *testing.T) {
wantJSONDataType telemetrytypes.JSONDataType
}{
{
name: "BarePath_KeptAsIs",
path: &PromotePath{Path: "user.name", Promote: true},
name: "ValidPath_BodyPrefixStripped",
path: &PromotePath{Path: "body.user.name", Promote: true},
wantPath: "user.name",
},
{
name: "BodyPrefixedPath_Rejected",
path: &PromotePath{Path: "body.user.name", Promote: true},
name: "PathWithoutBodyPrefix_Rejected",
path: &PromotePath{Path: "user.name", Promote: true},
wantErr: true,
},
{
@@ -114,7 +114,7 @@ func TestValidateAndSetDefaultsLogsBody(t *testing.T) {
{
name: "ValidIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "user.name",
Path: "body.user.name",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
@@ -122,83 +122,10 @@ func TestValidateAndSetDefaultsLogsBody(t *testing.T) {
wantPath: "user.name",
wantJSONDataType: telemetrytypes.String,
},
{
name: "ValidTokenBFIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "tokenbf_v1(1024, 2, 0)", Granularity: 1},
},
},
wantPath: "user.name",
wantJSONDataType: telemetrytypes.String,
},
{
name: "IndexTypeWithAlterAction_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4,1024,2,0)\tGRANULARITY\t1,\tDROP\tINDEX\tidx\t--\t", Granularity: 1}},
},
wantErr: true,
},
{
name: "ValidBloomFilterIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(0.01)", Granularity: 1},
},
},
wantPath: "user.name",
wantJSONDataType: telemetrytypes.String,
},
{
name: "ValidSetIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "set(100)", Granularity: 1},
},
},
wantPath: "user.name",
wantJSONDataType: telemetrytypes.String,
},
{
name: "IndexTypeFalsePositiveOutOfRange_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(1)", Granularity: 1}},
},
wantErr: true,
},
{
name: "UnsupportedIndexType_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "inverted(2)", Granularity: 1}},
},
wantErr: true,
},
{
name: "IndexTypeWrongArity_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024)", Granularity: 1}},
},
wantErr: true,
},
{
name: "IndexTypeOversizedBloomFilter_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 9999999, 2, 0)", Granularity: 1}},
},
wantErr: true,
},
{
name: "UnsupportedColumnTypeIndex_Rejected",
path: &PromotePath{
Path: "user.active",
Path: "body.user.active",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeBool, Type: "minmax", Granularity: 1}},
},
wantErr: true,
@@ -206,7 +133,7 @@ func TestValidateAndSetDefaultsLogsBody(t *testing.T) {
{
name: "IndexWithoutType_Rejected",
path: &PromotePath{
Path: "user.name",
Path: "body.user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Granularity: 1}},
},
wantErr: true,
@@ -222,7 +149,7 @@ func TestValidateAndSetDefaultsLogsBody(t *testing.T) {
{
name: "IndexWithoutGranularity_Rejected",
path: &PromotePath{
Path: "user.name",
Path: "body.user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "minmax"}},
},
wantErr: true,
@@ -250,28 +177,16 @@ func TestValidateAndSetDefaultsTracesAttributes(t *testing.T) {
target := NewTracesAttributesTarget()
testCases := []struct {
name string
path *PromotePath
wantErr bool
wantPath string
wantJSONDataType telemetrytypes.JSONDataType
name string
path *PromotePath
wantErr bool
wantPath string
}{
{
name: "BareAttributeName_KeptAsIs",
path: &PromotePath{Path: "http.method", Promote: true},
wantPath: "http.method",
},
{
name: "ValidIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "http.method",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
wantPath: "http.method",
wantJSONDataType: telemetrytypes.String,
},
{
name: "AttributesPrefixedPath_Rejected",
path: &PromotePath{Path: "attributes.http.method", Promote: true},
@@ -308,10 +223,6 @@ func TestValidateAndSetDefaultsTracesAttributes(t *testing.T) {
}
require.NoError(t, err)
assert.Equal(t, testCase.wantPath, testCase.path.Path)
if testCase.wantJSONDataType != (telemetrytypes.JSONDataType{}) {
require.Len(t, testCase.path.Indexes, 1)
assert.Equal(t, testCase.wantJSONDataType, testCase.path.Indexes[0].JSONDataType)
}
})
}
}
@@ -441,38 +352,3 @@ func TestListPromotedPathsFiltersMatch(t *testing.T) {
})
}
}
func TestValidatePromotePaths(t *testing.T) {
validPaths := func(n int) []*PromotePath {
paths := make([]*PromotePath, n)
for idx := range paths {
paths[idx] = &PromotePath{Signal: "logs", Context: "body", Path: "user.name"}
}
return paths
}
testCases := []struct {
name string
paths []*PromotePath
wantErr bool
}{
{name: "Empty_Rejected", paths: nil, wantErr: true},
{name: "NullPath_Rejected", paths: []*PromotePath{nil}, wantErr: true},
{name: "OverCap_Rejected", paths: validPaths(maxPromotePathsPerRequest + 1), wantErr: true},
{name: "PrefixedPath_Rejected", paths: []*PromotePath{{Signal: "logs", Context: "body", Path: "body.user.name"}}, wantErr: true},
{name: "UnsupportedSignal_Rejected", paths: []*PromotePath{{Signal: "metrics", Context: "body", Path: "user.name"}}, wantErr: true},
{name: "AtCap_Valid", paths: validPaths(maxPromotePathsPerRequest)},
{name: "SinglePath_Valid", paths: validPaths(1)},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := ValidatePromotePaths(testCase.paths)
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
})
}
}

View File

@@ -398,19 +398,6 @@ func NewTelemetryFieldKey(name string, fieldContext FieldContext, fieldDataType
}
}
// JSONIndexLookup locates one domain's JSON sub-column indexes in
// system.data_skipping_indices: the table to read, the base and promoted
// column prefixes to match index expressions against, and the signal and
// context to stamp on the results.
type JSONIndexLookup struct {
Signal Signal
FieldContext FieldContext
DBName string
LocalTableName string
BaseColumnPrefix string
PromotedColumnPrefix string
}
type TelemetryFieldKeySkipIndex struct {
Name string `json:"name"` // Name is TelemetryFieldKey.Name not IndexName from ClickHouse
FieldContext FieldContext `json:"fieldContext,omitzero"`

View File

@@ -34,8 +34,8 @@ type MetadataStore interface {
FetchTemporalityAndTypeMulti(ctx context.Context, orgID valuer.UUID, queryTimeRangeStartTs, queryTimeRangeEndTs uint64, metricNames ...string) (map[string]metrictypes.Temporality, map[string]metrictypes.Type, map[string]bool, error)
// ListJSONIndexes lists the per-path JSON skip indexes of the given source.
ListJSONIndexes(ctx context.Context, lookup JSONIndexLookup, filters ...string) ([]TelemetryFieldKeySkipIndex, error)
// ListLogsJSONIndexes lists the JSON indexes for the logs table.
ListLogsJSONIndexes(ctx context.Context, filters ...string) ([]TelemetryFieldKeySkipIndex, error)
// GetPromotedPaths lists the promoted paths recorded in the column
// evolution table for the entry's signal, column and field context.

View File

@@ -376,15 +376,9 @@ func (m *MockMetadataStore) GetPromotedPaths(_ context.Context, entry telemetryt
return m.PromotedPathsMap[entry.Signal], nil
}
// ListJSONIndexes narrows the stored indexes to the lookup's field context like the real query.
func (m *MockMetadataStore) ListJSONIndexes(ctx context.Context, lookup telemetrytypes.JSONIndexLookup, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
indexes := []telemetrytypes.TelemetryFieldKeySkipIndex{}
for _, index := range m.LogsJSONIndexes {
if index.FieldContext.StringValue() == lookup.FieldContext.StringValue() {
indexes = append(indexes, index)
}
}
return indexes, nil
// ListLogsJSONIndexes lists the JSON indexes for the logs table.
func (m *MockMetadataStore) ListLogsJSONIndexes(ctx context.Context, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
return m.LogsJSONIndexes, nil
}
func (m *MockMetadataStore) updateColumnEvolutionMetadataForKeys(_ context.Context, keysToUpdate []*telemetrytypes.TelemetryFieldKey) map[string][]*telemetrytypes.EvolutionEntry {