mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-20 00:50:41 +01:00
Compare commits
12 Commits
nv/alerts-
...
nv/context
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a5fcabda | ||
|
|
e3dc4ea9ac | ||
|
|
8551acf07d | ||
|
|
516ce300bc | ||
|
|
6c1f12f5d0 | ||
|
|
e6c8bae0c7 | ||
|
|
6210c0ac4b | ||
|
|
0f370aac64 | ||
|
|
b85b2fa87c | ||
|
|
2a9246bfe3 | ||
|
|
e59afbec38 | ||
|
|
b1e2164739 |
@@ -1,11 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip, TooltipProps } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: string;
|
||||
tooltip: TooltipProps['title'];
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 (
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
<Badge
|
||||
variant="outlined"
|
||||
color={color}
|
||||
testId={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
12
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
12
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { BadgeColorType } from '@signozhq/ui/badge';
|
||||
|
||||
import { AiSpanKind } from '../utils/aiTraceUtils';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: BadgeColorType }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'success' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'warning' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'highlight-danger' },
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,28 +9,17 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tooltip } 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';
|
||||
@@ -7,6 +8,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';
|
||||
|
||||
/**
|
||||
@@ -33,6 +39,7 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -42,33 +49,60 @@ 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'}
|
||||
testId="span-hover-card-status"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="start"
|
||||
value={`${toFixed(relativeStartMs, 2)} ms`}
|
||||
testId="span-hover-card-start"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
testId="span-hover-card-duration"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
@@ -130,6 +164,7 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getAIUsageDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -186,6 +186,8 @@ function TraceDetailsHeader({
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
tokens={traceSummary.ai?.tokens}
|
||||
cost={traceSummary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -30,13 +30,9 @@
|
||||
// lives here. Shares visual treatment with the waterfall hover popover
|
||||
// but is positioned `fixed` instead of anchored.
|
||||
.tooltip {
|
||||
composes: tooltipSurface from '../shared.module.scss';
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
|
||||
import { DEFAULT_ROW_HEIGHT } from './constants';
|
||||
import { useCanvasSetup } from './hooks/useCanvasSetup';
|
||||
import { useCursorTooltipPosition } from './hooks/useCursorTooltipPosition';
|
||||
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
@@ -200,15 +201,11 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
handleHoverMouseLeave,
|
||||
]);
|
||||
|
||||
const tooltipRef = useCursorTooltipPosition<HTMLDivElement>(tooltipContent);
|
||||
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
}}
|
||||
>
|
||||
<div ref={tooltipRef} className={styles.tooltip}>
|
||||
{tooltipContent.event ? (
|
||||
<EventTooltipContent
|
||||
eventName={tooltipContent.event.name}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RefObject, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
const CURSOR_OFFSET = 15;
|
||||
|
||||
interface CursorAnchor {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
function resolveAxis(cursor: number, size: number, viewport: number): number {
|
||||
const after = cursor + CURSOR_OFFSET;
|
||||
if (after + size <= viewport) {
|
||||
return after;
|
||||
}
|
||||
return Math.max(0, cursor - CURSOR_OFFSET - size);
|
||||
}
|
||||
|
||||
/** Places a fixed element beside the cursor, flipping sides when it would overflow the viewport. */
|
||||
export function useCursorTooltipPosition<T extends HTMLElement>(
|
||||
anchor: CursorAnchor | null,
|
||||
): RefObject<T> {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || !anchor) {
|
||||
return;
|
||||
}
|
||||
const left = resolveAxis(anchor.clientX, el.offsetWidth, window.innerWidth);
|
||||
const top = resolveAxis(anchor.clientY, el.offsetHeight, window.innerHeight);
|
||||
el.style.left = `${left}px`;
|
||||
el.style.top = `${top}px`;
|
||||
}, [anchor]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -203,8 +203,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;
|
||||
@@ -394,6 +395,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aiSpanIcon {
|
||||
flex-shrink: 0;
|
||||
margin: 0 var(--spacing-2);
|
||||
}
|
||||
|
||||
.treeLabel {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
|
||||
@@ -20,7 +20,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';
|
||||
@@ -28,7 +27,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';
|
||||
@@ -39,6 +44,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';
|
||||
|
||||
@@ -318,14 +325,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}>
|
||||
@@ -944,9 +960,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>
|
||||
);
|
||||
})}
|
||||
|
||||
20
frontend/src/pages/TraceDetailsV3/shared.module.scss
Normal file
20
frontend/src/pages/TraceDetailsV3/shared.module.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.tooltipSurface {
|
||||
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);
|
||||
}
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
traceDetailFieldValues,
|
||||
traceFlamegraphResponse,
|
||||
traceSummaryResponse,
|
||||
TRACE_SCENARIOS,
|
||||
type TraceScenario,
|
||||
traceWaterfallResponse,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
|
||||
@@ -103,6 +105,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:
|
||||
@@ -151,6 +160,7 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
},
|
||||
handlers: (values, response) => {
|
||||
const trace = {
|
||||
scenario: values.scenario,
|
||||
spans: values.spans,
|
||||
errors: values.errors,
|
||||
traceStart: traceStartOf(),
|
||||
@@ -288,7 +298,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) => {
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
|
||||
import {
|
||||
focusSpanIndex,
|
||||
scenarioSpanCount,
|
||||
spanIdAt,
|
||||
STORY_TRACE_ID,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
import {
|
||||
openRowMenu,
|
||||
startDownload,
|
||||
@@ -96,6 +101,40 @@ export const BottomStripLoading: Story = {
|
||||
args: { bottomStrip: true, dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
RESERVED_ERROR,
|
||||
} from './utils/generateColorPair';
|
||||
|
||||
/** Like `||`, but keeps `0`. */
|
||||
const valueOrUndefined = <T>(value: T): T | undefined =>
|
||||
value || value === 0 ? value : undefined;
|
||||
|
||||
/**
|
||||
* Look up an attribute from both `resource` and `attributes` on a span.
|
||||
* Resources are checked first (service.name, k8s.* etc. live there).
|
||||
* Falsy values count as missing, except `0`.
|
||||
*
|
||||
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
|
||||
* structurally — the only fields touched are `resource` and `attributes`.
|
||||
@@ -22,11 +27,14 @@ export function getSpanAttribute(
|
||||
span: {
|
||||
resource?: Record<string, string>;
|
||||
attributes?: Record<string, any>;
|
||||
tagMap?: Record<string, any>;
|
||||
},
|
||||
key: string,
|
||||
): string | undefined {
|
||||
return (
|
||||
span.resource?.[key] || span.attributes?.[key] || (span as any).tagMap?.[key]
|
||||
valueOrUndefined(span.resource?.[key]) ??
|
||||
valueOrUndefined(span.attributes?.[key]) ??
|
||||
valueOrUndefined(span.tagMap?.[key])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
168
frontend/src/pages/TraceDetailsV3/utils/aiTraceUtils.ts
Normal file
168
frontend/src/pages/TraceDetailsV3/utils/aiTraceUtils.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
|
||||
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,
|
||||
];
|
||||
|
||||
function hasAttribute(span: SpanV3, key: string): boolean {
|
||||
return getSpanAttribute(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 = getSpanAttribute(span, key);
|
||||
return value === undefined ? undefined : String(value);
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsNumber(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
const value = getSpanAttribute(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,
|
||||
};
|
||||
}
|
||||
@@ -3,10 +3,13 @@ package contextlinks
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
@@ -52,40 +55,66 @@ func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start
|
||||
return params
|
||||
}
|
||||
|
||||
// BuilderQueryForSignal returns the filter expression and group-by keys of the
|
||||
// builder query for the given signal, or found=false when the composite query
|
||||
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
|
||||
// queries (builder_ai_query) count as trace builder queries.
|
||||
// TODO(srikanthccv): re-visit this and support multiple queries.
|
||||
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
|
||||
switch signal {
|
||||
case telemetrytypes.SignalLogs:
|
||||
return builderQueryForSignal[qbtypes.LogAggregation](queries, signal)
|
||||
case telemetrytypes.SignalTraces:
|
||||
return builderQueryForSignal[qbtypes.TraceAggregation](queries, signal)
|
||||
func PrepareRuleFilterExpression(condition *ruletypes.RuleCondition, signal telemetrytypes.Signal, labels map[string]string) (string, bool, error) {
|
||||
var expressions []string
|
||||
if condition != nil && condition.CompositeQuery != nil {
|
||||
names, err := resolveSelectedQueryNames(condition)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
switch signal {
|
||||
case telemetrytypes.SignalLogs:
|
||||
expressions = prepareFilterExpressions[qbtypes.LogAggregation](condition.CompositeQuery.Queries, names, signal, labels)
|
||||
case telemetrytypes.SignalTraces:
|
||||
expressions = prepareFilterExpressions[qbtypes.TraceAggregation](condition.CompositeQuery.Queries, names, signal, labels)
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
|
||||
switch {
|
||||
case len(expressions) == 0:
|
||||
// a ClickHouse SQL alert has no builder query; its link filters on the labels alone
|
||||
return PrepareFilterExpression(labels, "", nil), false, nil
|
||||
case len(expressions) == 1:
|
||||
return expressions[0], true, nil
|
||||
case slices.Contains(expressions, ""):
|
||||
// an unfiltered formula input matches everything, so the merged filter does too
|
||||
return "", true, nil
|
||||
}
|
||||
// the explorer list views render one query only, so a formula's inputs merge into one filter
|
||||
return "(" + strings.Join(expressions, ") OR (") + ")", true, nil
|
||||
}
|
||||
|
||||
func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
|
||||
var q qbtypes.QueryBuilderQuery[T]
|
||||
found := false
|
||||
func resolveSelectedQueryNames(condition *ruletypes.RuleCondition) ([]string, error) {
|
||||
selected := condition.SelectedQueryName()
|
||||
for _, query := range condition.CompositeQuery.Queries {
|
||||
if query.GetQueryName() != selected {
|
||||
continue
|
||||
}
|
||||
if formula, ok := query.Spec.(qbtypes.QueryBuilderFormula); ok {
|
||||
return formula.ExtractReferencedQueryNames()
|
||||
}
|
||||
return []string{selected}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func prepareFilterExpressions[T any](queries []qbtypes.QueryEnvelope, names []string, signal telemetrytypes.Signal, labels map[string]string) []string {
|
||||
var expressions []string
|
||||
for _, query := range queries {
|
||||
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
|
||||
continue
|
||||
}
|
||||
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {
|
||||
q = spec
|
||||
found = true
|
||||
spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T])
|
||||
// spec.Disabled is deliberately not checked: a formula's inputs are
|
||||
// usually hidden in the UI but must still make it into the link
|
||||
if !ok || spec.Signal != signal || !slices.Contains(names, spec.Name) {
|
||||
continue
|
||||
}
|
||||
filterExpr := ""
|
||||
if spec.Filter != nil {
|
||||
filterExpr = spec.Filter.Expression
|
||||
}
|
||||
expressions = append(expressions, PrepareFilterExpression(labels, filterExpr, spec.GroupBy))
|
||||
}
|
||||
if !found || q.Signal != signal {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if q.Filter != nil {
|
||||
filterExpr = q.Filter.Expression
|
||||
}
|
||||
return filterExpr, q.GroupBy, true
|
||||
return expressions
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuilderQueryForSignal(t *testing.T) {
|
||||
func TestPrepareRuleFilterExpression(t *testing.T) {
|
||||
logQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
@@ -19,62 +20,205 @@ func TestBuilderQueryForSignal(t *testing.T) {
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
},
|
||||
}
|
||||
hiddenLogQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Disabled: true,
|
||||
Filter: &qbtypes.Filter{Expression: "body CONTAINS 'timeout'"},
|
||||
},
|
||||
}
|
||||
unfilteredLogQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
},
|
||||
}
|
||||
traceQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "B",
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
},
|
||||
}
|
||||
promQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypePromQL,
|
||||
Spec: qbtypes.PromQuery{Name: "C"},
|
||||
Spec: qbtypes.PromQuery{Name: "E"},
|
||||
}
|
||||
aiTraceQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "D",
|
||||
Name: "F",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
|
||||
},
|
||||
}
|
||||
errorRateFormula := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{Name: "F1", Expression: "B / A * 100"},
|
||||
}
|
||||
unfilteredFormula := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{Name: "F2", Expression: "A / C"},
|
||||
}
|
||||
brokenFormula := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{Name: "F3", Expression: "A +"},
|
||||
}
|
||||
|
||||
t.Run("logs query among mixed queries", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "severity_text = 'ERROR'", filterExpr)
|
||||
require.Len(t, groupBy, 1)
|
||||
assert.Equal(t, "service.name", groupBy[0].Name)
|
||||
})
|
||||
testCases := []struct {
|
||||
name string
|
||||
condition *ruletypes.RuleCondition
|
||||
signal telemetrytypes.Signal
|
||||
labels map[string]string
|
||||
expectedError bool
|
||||
expectedExpression string
|
||||
expectedFound bool
|
||||
}{
|
||||
{
|
||||
name: "SelectedLogsQuery_AmongMixedQueries",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "A",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "(severity_text='ERROR') AND service.name='checkout'",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedLogsQuery_LeavesOutUnselectedQuery",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "A",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "(severity_text='ERROR') AND service.name='checkout'",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedFormula_MergesHiddenInputsInCompositeQueryOrder",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "F1",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{hiddenLogQuery, logQuery, errorRateFormula}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "((body CONTAINS 'timeout') AND service.name='checkout') OR ((severity_text='ERROR') AND service.name='checkout')",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedFormula_UnfilteredInputMatchesEverything",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "F2",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, unfilteredLogQuery, unfilteredFormula}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{},
|
||||
expectedExpression: "",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedFormula_InvalidExpression",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "F3",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, brokenFormula}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "NoSelectedQuery_FallsBackToFormula",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery, errorRateFormula}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "((severity_text='ERROR') AND service.name='checkout') OR ((body CONTAINS 'timeout') AND service.name='checkout')",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "NoSelectedQuery_OnlyHiddenQueries",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{hiddenLogQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{},
|
||||
expectedExpression: "",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "SelectedTracesQuery_WithoutFilter",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "D",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "service.name='checkout'",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedAITraceQuery_CountsAsTraces",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "F",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, aiTraceQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
labels: map[string]string{"session.id": "s1"},
|
||||
expectedExpression: "(trace.input_tokens>1000) AND session.id='s1'",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedQuery_OfAnotherSignal_LabelsOnly",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "D",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "service.name='checkout'",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "SelectedQuery_NotABuilderQuery",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "E",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{},
|
||||
expectedExpression: "",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "UnsupportedSignal_LabelsOnly",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "A",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalMetrics,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "service.name='checkout'",
|
||||
expectedFound: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("traces query without filter", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, traceQuery}, telemetrytypes.SignalTraces)
|
||||
require.True(t, found)
|
||||
assert.Empty(t, filterExpr)
|
||||
assert.Empty(t, groupBy)
|
||||
})
|
||||
|
||||
t.Run("ai trace query counts as traces", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
|
||||
require.Len(t, groupBy, 1)
|
||||
assert.Equal(t, "session.id", groupBy[0].Name)
|
||||
})
|
||||
|
||||
t.Run("no builder query for signal", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("no builder queries at all", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery}, telemetrytypes.SignalLogs)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("unsupported signal", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery}, telemetrytypes.SignalMetrics)
|
||||
assert.False(t, found)
|
||||
})
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
expression, found, err := PrepareRuleFilterExpression(testCase.condition, testCase.signal, testCase.labels)
|
||||
if testCase.expectedError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expectedExpression, expression)
|
||||
assert.Equal(t, testCase.expectedFound, found)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package implrulestatehistory
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/contextlinks"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
@@ -18,9 +20,10 @@ import (
|
||||
// explorer with the context that produced it.
|
||||
type relatedLinkBuilder struct {
|
||||
alertType ruletypes.AlertType
|
||||
signal telemetrytypes.Signal
|
||||
evaluation ruletypes.Evaluation
|
||||
filterExpr string
|
||||
groupBy []qbtypes.GroupByKey
|
||||
condition *ruletypes.RuleCondition
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// relatedLinkBuilderForRule returns nil when the rule cannot be loaded or is
|
||||
@@ -49,7 +52,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
|
||||
return nil
|
||||
}
|
||||
|
||||
builder := &relatedLinkBuilder{alertType: rule.AlertType}
|
||||
builder := &relatedLinkBuilder{alertType: rule.AlertType, signal: signal, condition: rule.RuleCondition, logger: m.logger.With(slog.String("rule.id", ruleID))}
|
||||
if rule.Evaluation != nil {
|
||||
if evaluation, err := rule.Evaluation.GetEvaluation(); err == nil {
|
||||
builder.evaluation = evaluation
|
||||
@@ -63,10 +66,6 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
|
||||
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
|
||||
}
|
||||
|
||||
// links are still built from the labels alone when the rule has no builder
|
||||
// query for the signal (e.g. ClickHouse SQL alerts)
|
||||
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
@@ -83,13 +82,19 @@ func (b *relatedLinkBuilder) queryWindow(unixMilli int64) (time.Time, time.Time)
|
||||
|
||||
// links returns the explorer query params for the given entry labels and time
|
||||
// range.
|
||||
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
|
||||
func (b *relatedLinkBuilder) links(ctx context.Context, labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
|
||||
lbls := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(labels), &lbls); err != nil {
|
||||
return rulestatehistorytypes.RelatedLinks{}
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
|
||||
// links are still built from the labels alone when the rule has no builder
|
||||
// query for the signal (e.g. ClickHouse SQL alerts)
|
||||
whereClause, _, err := contextlinks.PrepareRuleFilterExpression(b.condition, b.signal, lbls)
|
||||
if err != nil {
|
||||
b.logger.ErrorContext(ctx, "failed to prepare the filter for related links", errors.Attr(err))
|
||||
return rulestatehistorytypes.RelatedLinks{}
|
||||
}
|
||||
|
||||
switch b.alertType {
|
||||
case ruletypes.AlertTypeLogs:
|
||||
|
||||
@@ -2,6 +2,7 @@ package implrulestatehistory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
@@ -15,10 +16,11 @@ import (
|
||||
type module struct {
|
||||
store rulestatehistorytypes.Store
|
||||
ruleStore ruletypes.RuleStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewModule(store rulestatehistorytypes.Store, ruleStore ruletypes.RuleStore) rulestatehistory.Module {
|
||||
return &module{store: store, ruleStore: ruleStore}
|
||||
func NewModule(store rulestatehistorytypes.Store, ruleStore ruletypes.RuleStore, logger *slog.Logger) rulestatehistory.Module {
|
||||
return &module{store: store, ruleStore: ruleStore, logger: logger}
|
||||
}
|
||||
|
||||
func (m *module) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]rulestatehistorytypes.RuleStateHistory, error) {
|
||||
@@ -34,7 +36,7 @@ func (m *module) GetHistoryTimeline(ctx context.Context, orgID valuer.UUID, rule
|
||||
if builder := m.relatedLinkBuilderForRule(ctx, orgID, ruleID); builder != nil {
|
||||
for idx := range items {
|
||||
start, end := builder.queryWindow(items[idx].UnixMilli)
|
||||
items[idx].RelatedLinks = builder.links(items[idx].Labels, start, end)
|
||||
items[idx].RelatedLinks = builder.links(ctx, items[idx].Labels, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +62,7 @@ func (m *module) GetHistoryContributors(ctx context.Context, orgID valuer.UUID,
|
||||
// span it too instead of a single evaluation window
|
||||
start, end := time.UnixMilli(query.Start), time.UnixMilli(query.End)
|
||||
for idx := range contributors {
|
||||
contributors[idx].RelatedLinks = builder.links(contributors[idx].Labels, start, end)
|
||||
contributors[idx].RelatedLinks = builder.links(ctx, contributors[idx].Labels, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,9 +85,7 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, resultLabels ruletypes.Labels) url.Values {
|
||||
qr, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -95,24 +93,19 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
|
||||
start := time.UnixMilli(int64(qr.Start))
|
||||
end := time.UnixMilli(int64(qr.End))
|
||||
|
||||
// TODO(srikanthccv): handle formula queries
|
||||
if selectedQuery < "A" || selectedQuery > "Z" {
|
||||
whereClause, found, err := contextlinks.PrepareRuleFilterExpression(r.ruleCondition, telemetrytypes.SignalLogs, resultLabels.Map())
|
||||
if err != nil {
|
||||
r.logger.ErrorContext(ctx, "failed to prepare the filter for the logs link", errors.Attr(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalLogs)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
|
||||
|
||||
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, resultLabels ruletypes.Labels) url.Values {
|
||||
qr, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -120,18 +113,15 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
|
||||
start := time.UnixMilli(int64(qr.Start))
|
||||
end := time.UnixMilli(int64(qr.End))
|
||||
|
||||
// TODO(srikanthccv): handle formula queries
|
||||
if selectedQuery < "A" || selectedQuery > "Z" {
|
||||
whereClause, found, err := contextlinks.PrepareRuleFilterExpression(r.ruleCondition, telemetrytypes.SignalTraces, resultLabels.Map())
|
||||
if err != nil {
|
||||
r.logger.ErrorContext(ctx, "failed to prepare the filter for the traces link", errors.Attr(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalTraces)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
|
||||
|
||||
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ func NewModules(
|
||||
ServiceAccount: serviceAccount,
|
||||
ServiceAccountGetter: serviceAccountGetter,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore, providerSettings.Logger),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
SpanMapper: spanMapper,
|
||||
|
||||
@@ -131,6 +131,22 @@ func (f QueryBuilderFormula) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f QueryBuilderFormula) ExtractReferencedQueryNames() ([]string, error) {
|
||||
// parsed the way the evaluator parses it, so the names match what it resolves
|
||||
expression, err := govaluate.NewEvaluableExpressionWithFunctions(f.Expression, EvalFuncs())
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse expression for formula query %q", f.Name)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, variable := range expression.Vars() {
|
||||
if !slices.Contains(names, variable) {
|
||||
names = append(names, variable)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// small container to store the query name and index or alias reference
|
||||
// for a variable in the formula expression
|
||||
// read below for more details on aggregation references.
|
||||
|
||||
@@ -126,6 +126,12 @@ type RuleCondition struct {
|
||||
}
|
||||
|
||||
func (rc *RuleCondition) SelectedQueryName() string {
|
||||
if rc.SelectedQuery != "" {
|
||||
return rc.SelectedQuery
|
||||
}
|
||||
if rc.CompositeQuery == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
queryNames := map[string]struct{}{}
|
||||
|
||||
@@ -169,6 +175,9 @@ func (rc *RuleCondition) SelectedQueryName() string {
|
||||
for k := range queryNames {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys[len(keys)-1]
|
||||
}
|
||||
|
||||
@@ -56,12 +56,18 @@ test.describe('Trace details — flamegraph', () => {
|
||||
}) => {
|
||||
await hoverFlamegraphSpan(page, trace.landmarks.errors[0]);
|
||||
|
||||
// "status: error" only renders in the hover card (not in waterfall rows),
|
||||
// The status row only renders in the hover card (not in waterfall rows),
|
||||
// so it proves both that the card opened and that we hovered the right
|
||||
// (error) span — the bar was targeted by id via the span→rect map.
|
||||
await expect(page.getByText('status: error')).toBeVisible();
|
||||
await expect(page.getByText(/start: [\d.]+ ms/)).toBeVisible();
|
||||
await expect(page.getByText(/duration: [\d.]+/)).toBeVisible();
|
||||
await expect(page.getByTestId('span-hover-card-status')).toHaveText(
|
||||
/status\s*error/,
|
||||
);
|
||||
await expect(page.getByTestId('span-hover-card-start')).toHaveText(
|
||||
/start\s*[\d.]+ ms/,
|
||||
);
|
||||
await expect(page.getByTestId('span-hover-card-duration')).toHaveText(
|
||||
/duration\s*[\d.]+/,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 clicking a bar selects the span, opens the drawer, and syncs the waterfall row', async ({
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{ "timestamp": "2026-01-29T10:00:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:00:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:01:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:01:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:02:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:02:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:03:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:03:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
104
tests/integration/testdata/ruler/test_scenarios/rule_state_history_formula_logs/rule.json
vendored
Normal file
104
tests/integration/testdata/ruler/test_scenarios/rule_state_history_formula_logs/rule.json
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"alert": "rule_state_history_formula_logs",
|
||||
"ruleType": "threshold_rule",
|
||||
"alertType": "LOGS_BASED_ALERT",
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
{
|
||||
"name": "critical",
|
||||
"target": 0,
|
||||
"matchType": "at_least_once",
|
||||
"op": "above",
|
||||
"channels": [
|
||||
"test channel"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"disabled": true,
|
||||
"filter": {
|
||||
"expression": "body CONTAINS 'payment success'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "logs",
|
||||
"disabled": true,
|
||||
"filter": {
|
||||
"expression": "severity_text = 'INFO'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_formula",
|
||||
"spec": {
|
||||
"name": "F1",
|
||||
"expression": "A / B * 100"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectedQueryName": "F1"
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m0s",
|
||||
"frequency": "15s"
|
||||
}
|
||||
},
|
||||
"labels": {},
|
||||
"annotations": {
|
||||
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
||||
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
|
||||
},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": false,
|
||||
"renotify": {
|
||||
"enabled": false,
|
||||
"interval": "30m",
|
||||
"alertStates": []
|
||||
}
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{ "timestamp": "2026-01-29T10:00:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:00:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:01:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:01:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:02:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:02:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:03:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:03:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
88
tests/integration/testdata/ruler/test_scenarios/rule_state_history_multi_query_logs/rule.json
vendored
Normal file
88
tests/integration/testdata/ruler/test_scenarios/rule_state_history_multi_query_logs/rule.json
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"alert": "rule_state_history_multi_query_logs",
|
||||
"ruleType": "threshold_rule",
|
||||
"alertType": "LOGS_BASED_ALERT",
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
{
|
||||
"name": "critical",
|
||||
"target": 0,
|
||||
"matchType": "at_least_once",
|
||||
"op": "above",
|
||||
"channels": [
|
||||
"test channel"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"filter": {
|
||||
"expression": "body CONTAINS 'payment success'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "logs",
|
||||
"filter": {
|
||||
"expression": "severity_text = 'INFO'"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectedQueryName": "A"
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m0s",
|
||||
"frequency": "15s"
|
||||
}
|
||||
},
|
||||
"labels": {},
|
||||
"annotations": {
|
||||
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
||||
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
|
||||
},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": false,
|
||||
"renotify": {
|
||||
"enabled": false,
|
||||
"interval": "30m",
|
||||
"alertStates": []
|
||||
}
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1"
|
||||
}
|
||||
@@ -151,3 +151,81 @@ def test_ai_traces_rule_history_related_links(
|
||||
assert contributor_link["end"] == query_end_ms * 1_000_000
|
||||
assert_related_link_query(contributor_link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
|
||||
assert contributor_link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"
|
||||
|
||||
|
||||
def test_multi_query_logs_rule_history_related_links(
|
||||
signoz: types.SigNoz,
|
||||
create_alert_rule_with_channel: Callable[[str], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query_start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=30)).timestamp() * 1000)
|
||||
|
||||
insert_alert_data(
|
||||
[types.AlertData(type="logs", data_path="ruler/test_scenarios/rule_state_history_multi_query_logs/alert_data.jsonl")],
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
|
||||
)
|
||||
rule_id = create_alert_rule_with_channel("ruler/test_scenarios/rule_state_history_multi_query_logs/rule.json")
|
||||
|
||||
(item, query_end_ms) = wait_for_firing_timeline_entry(signoz, token, rule_id, query_start_ms)
|
||||
|
||||
assert labels_to_map(item["labels"]).get("service.name") == "payment-service"
|
||||
assert item.get("relatedLogsLink", "") != ""
|
||||
|
||||
# the rule evaluates query A alone, so query B plays no part in the alert
|
||||
# and its filter stays out of the link
|
||||
link = parse_related_link(item["relatedLogsLink"])
|
||||
assert link["composite_query"]["builder"]["queryData"] == [{"dataSource": "logs", "filter": {"expression": "(body CONTAINS 'payment success') AND service.name='payment-service'"}}]
|
||||
|
||||
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
|
||||
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "payment-service"]
|
||||
assert len(contributors) == 1
|
||||
assert contributors[0].get("relatedLogsLink", "") != ""
|
||||
|
||||
contributor_link = parse_related_link(contributors[0]["relatedLogsLink"])
|
||||
assert contributor_link["composite_query"]["builder"]["queryData"] == [{"dataSource": "logs", "filter": {"expression": "(body CONTAINS 'payment success') AND service.name='payment-service'"}}]
|
||||
|
||||
|
||||
def test_formula_logs_rule_history_related_links(
|
||||
signoz: types.SigNoz,
|
||||
create_alert_rule_with_channel: Callable[[str], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query_start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=30)).timestamp() * 1000)
|
||||
|
||||
insert_alert_data(
|
||||
[types.AlertData(type="logs", data_path="ruler/test_scenarios/rule_state_history_formula_logs/alert_data.jsonl")],
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
|
||||
)
|
||||
rule_id = create_alert_rule_with_channel("ruler/test_scenarios/rule_state_history_formula_logs/rule.json")
|
||||
|
||||
(item, query_end_ms) = wait_for_firing_timeline_entry(signoz, token, rule_id, query_start_ms)
|
||||
|
||||
assert labels_to_map(item["labels"]).get("service.name") == "payment-service"
|
||||
assert item.get("relatedLogsLink", "") != ""
|
||||
|
||||
# the rule evaluates formula F1 over the hidden queries A and B, so the
|
||||
# link's one query ORs both filters, each narrowed to the firing series' labels
|
||||
link = parse_related_link(item["relatedLogsLink"])
|
||||
assert link["composite_query"]["builder"]["queryData"] == [
|
||||
{
|
||||
"dataSource": "logs",
|
||||
"filter": {"expression": "((body CONTAINS 'payment success') AND service.name='payment-service') OR ((severity_text='INFO') AND service.name='payment-service')"},
|
||||
}
|
||||
]
|
||||
|
||||
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
|
||||
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "payment-service"]
|
||||
assert len(contributors) == 1
|
||||
assert contributors[0].get("relatedLogsLink", "") != ""
|
||||
|
||||
contributor_link = parse_related_link(contributors[0]["relatedLogsLink"])
|
||||
assert contributor_link["composite_query"]["builder"]["queryData"] == [
|
||||
{
|
||||
"dataSource": "logs",
|
||||
"filter": {"expression": "((body CONTAINS 'payment success') AND service.name='payment-service') OR ((severity_text='INFO') AND service.name='payment-service')"},
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user