mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-13 05:30:41 +01:00
Compare commits
10 Commits
feat/trace
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a7db2ea3 | ||
|
|
3991224508 | ||
|
|
a64aadcd81 | ||
|
|
3de4d6eaea | ||
|
|
e18afc0178 | ||
|
|
2bac73ee5c | ||
|
|
552f35e5bb | ||
|
|
9eba4a794f | ||
|
|
8a0c6c9911 | ||
|
|
d3662e66c7 |
@@ -3425,6 +3425,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -4167,6 +4174,7 @@ components:
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -4182,6 +4190,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4195,6 +4204,7 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4280,6 +4290,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4359,6 +4381,16 @@ components:
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -4532,6 +4564,104 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
sizeBy:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesScatterPlotThreshold:
|
||||
properties:
|
||||
axis:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
|
||||
color:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -4645,6 +4775,11 @@ components:
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdAxis:
|
||||
enum:
|
||||
- x
|
||||
- "y"
|
||||
type: string
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
|
||||
@@ -7,7 +7,3 @@
|
||||
cursor: default;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: TooltipSimpleProps['title'];
|
||||
tooltip: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -16,10 +16,7 @@ function EntityMetadataItem({
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={tooltip}
|
||||
tooltipContentProps={{ className: styles.tooltip }}
|
||||
>
|
||||
<TooltipSimple title={tooltip}>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
|
||||
@@ -8,12 +8,3 @@
|
||||
// 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,18 +1,10 @@
|
||||
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
|
||||
import { CalendarClock, 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';
|
||||
|
||||
@@ -24,8 +16,6 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: AiTokenCounts;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -42,11 +32,8 @@ 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.
|
||||
@@ -104,24 +91,6 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -133,8 +102,6 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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,11 +1,9 @@
|
||||
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,
|
||||
@@ -33,10 +31,6 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const aiUsage = useMemo(
|
||||
() => getAIUsageDetails(selectedSpan)?.usage,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,8 +63,6 @@ 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,26 +95,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/aiTraceUtils';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
// The outline variant derives text, fill and border from this color.
|
||||
style={{ '--badge-background': color } as CSSProperties}
|
||||
data-testid={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { AiSpanKind } from '../utils/aiTraceUtils';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
|
||||
};
|
||||
@@ -8,12 +8,16 @@
|
||||
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,24 +9,40 @@
|
||||
}
|
||||
|
||||
.popover {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 223px;
|
||||
max-width: 360px;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
composes: tooltipTitle from '../shared.module.scss';
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-2);
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -13,11 +12,6 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -44,7 +38,6 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -54,54 +47,33 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<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 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>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TraceTooltipSection>
|
||||
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
|
||||
<TraceTooltipRow label="status" value={hasError ? 'error' : 'ok'} />
|
||||
<TraceTooltipRow
|
||||
label="start"
|
||||
value={`${toFixed(relativeStartMs, 2)} ms`}
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<TraceTooltipSection>
|
||||
<div className={styles.preview}>
|
||||
{previewRows.map((row) => (
|
||||
<TraceTooltipRow
|
||||
<div
|
||||
key={row.key}
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${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>
|
||||
))}
|
||||
</TraceTooltipSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -165,7 +137,6 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getAIUsageDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
@@ -1,81 +0,0 @@
|
||||
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;
|
||||
@@ -195,8 +195,6 @@ function TraceDetailsHeader({
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
tokens={traceSummary.ai?.tokens}
|
||||
cost={traceSummary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -177,19 +176,9 @@ 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?: SpantypesGettableTraceSummaryDTO): void => {
|
||||
const mockSummary = (data?: typeof traceMetadata): void => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
|
||||
};
|
||||
|
||||
@@ -212,77 +201,6 @@ 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,9 +30,13 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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;
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
@@ -30,8 +30,12 @@
|
||||
// loads cross-module composes through the plain CSS parser, which chokes on
|
||||
// SCSS `//` comments in the target file.
|
||||
.popover {
|
||||
composes: tooltipContent from '../../../shared.module.scss';
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -213,9 +217,8 @@
|
||||
.statusCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding-left: var(--spacing-2);
|
||||
|
||||
--badge-border-radius: 2px;
|
||||
--badge-padding: 3px 6px;
|
||||
@@ -405,11 +408,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aiSpanIcon {
|
||||
flex-shrink: 0;
|
||||
margin: 0 var(--spacing-2);
|
||||
}
|
||||
|
||||
.treeLabel {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
|
||||
@@ -25,6 +25,7 @@ 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';
|
||||
@@ -32,13 +33,7 @@ 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,
|
||||
Sparkle,
|
||||
} from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
@@ -49,8 +44,6 @@ 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';
|
||||
|
||||
@@ -335,23 +328,14 @@ const SpanOverview = memo(function SpanOverview({
|
||||
</span>
|
||||
|
||||
{/* Colored service dot */}
|
||||
{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
|
||||
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}>
|
||||
@@ -987,7 +971,9 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
<SpanHintBadge span={span} />
|
||||
{span.response_status_code && (
|
||||
<HttpStatusBadge statusCode={span.response_status_code} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
--tooltip-background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 60%,
|
||||
transparent
|
||||
);
|
||||
--tooltip-border-color: var(--l2-border);
|
||||
--tooltip-border-radius: var(--radius-2);
|
||||
--tooltip-padding: var(--spacing-4);
|
||||
--tooltip-box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
/* Above the docked span details FloatingPanel (z-index 999). */
|
||||
--tooltip-z-index: 1000;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
@@ -46,8 +46,6 @@ import {
|
||||
traceDetailFieldValues,
|
||||
traceFlamegraphResponse,
|
||||
traceSummaryResponse,
|
||||
TRACE_SCENARIOS,
|
||||
type TraceScenario,
|
||||
traceWaterfallResponse,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
|
||||
@@ -105,13 +103,6 @@ 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:
|
||||
@@ -160,7 +151,6 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
},
|
||||
handlers: (values, response) => {
|
||||
const trace = {
|
||||
scenario: values.scenario,
|
||||
spans: values.spans,
|
||||
errors: values.errors,
|
||||
traceStart: traceStartOf(),
|
||||
@@ -298,7 +288,7 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
},
|
||||
config: (values) => ({
|
||||
route: values.spanPanel
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.spans))}`
|
||||
: `/trace/${STORY_TRACE_ID}`,
|
||||
}),
|
||||
effect: (values) => {
|
||||
|
||||
@@ -13,12 +13,7 @@ import {
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
focusSpanIndex,
|
||||
scenarioSpanCount,
|
||||
spanIdAt,
|
||||
STORY_TRACE_ID,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
|
||||
import {
|
||||
openRowMenu,
|
||||
startDownload,
|
||||
@@ -101,40 +96,6 @@ 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,10 +23,6 @@ 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>;
|
||||
}
|
||||
|
||||
@@ -127,145 +123,6 @@ 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)),
|
||||
];
|
||||
@@ -289,7 +146,6 @@ interface BuiltSpan {
|
||||
}
|
||||
|
||||
interface TraceOptions {
|
||||
scenario: TraceScenario;
|
||||
spans: number;
|
||||
errors: boolean;
|
||||
/** Epoch milliseconds the root span starts at. */
|
||||
@@ -300,33 +156,24 @@ export const spanIdAt = (index: number): string =>
|
||||
`b17e${index.toString(16).padStart(12, '0')}`;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
export const focusSpanIndex = (spans: number): number => {
|
||||
const failing = SPAN_TEMPLATES.findIndex(({ failing }) => failing);
|
||||
|
||||
return index >= 0 && index < spans ? index : 0;
|
||||
return failing >= 0 && failing < spans ? failing : 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 = templates[index % templates.length];
|
||||
const template = SPAN_TEMPLATES[index % SPAN_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.
|
||||
@@ -410,8 +257,7 @@ 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.template.statusCode ?? (span.hasError ? '503' : '200'),
|
||||
response_status_code: span.hasError ? '503' : '200',
|
||||
has_children: span.hasChildren,
|
||||
sub_tree_node_count: span.subTreeNodeCount,
|
||||
level: span.level,
|
||||
@@ -458,36 +304,6 @@ 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 => {
|
||||
@@ -505,7 +321,6 @@ export const traceSummaryResponse = (
|
||||
totalSpansCount: spans.length,
|
||||
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
|
||||
hasMissingSpans: options.missingSpans,
|
||||
ai: traceAiSummary(spans),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -657,7 +472,7 @@ export const spanLogRows = (
|
||||
): RawRow[] => {
|
||||
const step = ROOT_DURATION_MS / Math.max(count, 1);
|
||||
const spans = buildSpans(options);
|
||||
const focus = spans[focusSpanIndex(options.scenario, options.spans)];
|
||||
const focus = spans[focusSpanIndex(options.spans)];
|
||||
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const at = options.traceStart + SCOPE_OFFSET_MS[scope] + index * step;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export const GEN_AI_KEYS = {
|
||||
requestModel: 'gen_ai.request.model',
|
||||
toolName: 'gen_ai.tool.name',
|
||||
agentName: 'gen_ai.agent.name',
|
||||
inputTokens: 'gen_ai.usage.input_tokens',
|
||||
outputTokens: 'gen_ai.usage.output_tokens',
|
||||
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
|
||||
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
|
||||
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
|
||||
cost: 'signoz.gen_ai.usage.tokens.cost',
|
||||
} as const;
|
||||
|
||||
export enum AiSpanKind {
|
||||
Llm = 'llm',
|
||||
Tool = 'tool',
|
||||
Agent = 'agent',
|
||||
}
|
||||
|
||||
const LLM_KEYS = [
|
||||
GEN_AI_KEYS.requestModel,
|
||||
GEN_AI_KEYS.inputTokens,
|
||||
GEN_AI_KEYS.outputTokens,
|
||||
GEN_AI_KEYS.cacheReadTokens,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
GEN_AI_KEYS.reasoningTokens,
|
||||
];
|
||||
|
||||
/** Keeps numeric 0, which `getSpanAttribute`'s `||` chain drops. */
|
||||
export function getGenAiValue(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): string | number | boolean | undefined {
|
||||
const value = span.attributes?.[key] ?? span.resource?.[key];
|
||||
return value === '' ? undefined : value;
|
||||
}
|
||||
|
||||
function hasAttribute(span: SpanV3, key: string): boolean {
|
||||
return getGenAiValue(span, key) !== undefined;
|
||||
}
|
||||
|
||||
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
|
||||
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
|
||||
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
|
||||
return AiSpanKind.Tool;
|
||||
}
|
||||
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
|
||||
return AiSpanKind.Agent;
|
||||
}
|
||||
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
|
||||
return AiSpanKind.Llm;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isAiSpan(span: SpanV3): boolean {
|
||||
return getAiSpanKind(span) !== undefined;
|
||||
}
|
||||
|
||||
/** Token counts; `null` or missing means the backend did not send the value. */
|
||||
export interface AiTokenCounts {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
export function hasValue(value: number | null | undefined): value is number {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input including cache read/write, counted once. Providers that report cache
|
||||
* inside `input` (OpenAI) have `input >= cache`; those that report it
|
||||
* separately (Anthropic) usually have `input < cache`.
|
||||
*/
|
||||
export function getTotalInputTokens({
|
||||
input,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
}: AiTokenCounts): number {
|
||||
const inputValue = input ?? 0;
|
||||
const cache = (cacheRead ?? 0) + (cacheWrite ?? 0);
|
||||
return inputValue < cache ? inputValue + cache : inputValue;
|
||||
}
|
||||
|
||||
/** `Input Token` for a count of 1, `Input Tokens` otherwise. */
|
||||
export function getTokenLabel(
|
||||
prefix: string,
|
||||
count: number | null | undefined,
|
||||
): string {
|
||||
return `${prefix} ${count === 1 ? 'Token' : 'Tokens'}`;
|
||||
}
|
||||
|
||||
/** Missing values render as `0`. */
|
||||
export function formatTokens(value: number | null | undefined): string {
|
||||
return hasValue(value) ? value.toLocaleString('en-US') : '0';
|
||||
}
|
||||
|
||||
export function formatCost(value: number): string {
|
||||
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
|
||||
}
|
||||
|
||||
export interface SpanAiUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export interface SpanAiDetails {
|
||||
model?: string;
|
||||
toolName?: string;
|
||||
agentName?: string;
|
||||
usage?: SpanAiUsage;
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsString(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = getGenAiValue(span, key);
|
||||
return value === undefined ? undefined : String(value);
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsNumber(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
const value = getGenAiValue(span, key);
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function getAIUsageDetails(span: SpanV3): SpanAiDetails | undefined {
|
||||
const usage: SpanAiUsage = {
|
||||
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
|
||||
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
|
||||
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
|
||||
cacheCreationTokens: getGenAiAttributeAsNumber(
|
||||
span,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
),
|
||||
reasoningTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.reasoningTokens),
|
||||
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
|
||||
};
|
||||
const hasUsage = Object.values(usage).some((value) => value !== undefined);
|
||||
|
||||
const details: SpanAiDetails = {
|
||||
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
|
||||
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
|
||||
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
|
||||
usage: hasUsage ? usage : undefined,
|
||||
};
|
||||
|
||||
return details.model || details.toolName || details.agentName || hasUsage
|
||||
? details
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function convertToAiTokens(usage: SpanAiUsage): AiTokenCounts {
|
||||
return {
|
||||
input: usage.inputTokens,
|
||||
output: usage.outputTokens,
|
||||
cacheRead: usage.cacheReadTokens,
|
||||
cacheWrite: usage.cacheCreationTokens,
|
||||
reasoning: usage.reasoningTokens,
|
||||
};
|
||||
}
|
||||
@@ -123,10 +123,22 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// validatePanelQueryCount left exactly one query for a spec that reads it.
|
||||
if spec, ok := panel.Spec.Plugin.Spec.(queryBoundPanelSpec); ok {
|
||||
if err := spec.validateAgainstQuery(panel.Spec.Queries[0], path+".spec.plugin.spec"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryBoundPanelSpec is a panel spec with fields that depend on the panel's
|
||||
// query. It is checked after the query itself validates.
|
||||
type queryBoundPanelSpec interface {
|
||||
validateAgainstQuery(query Query, path string) error
|
||||
}
|
||||
|
||||
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
|
||||
if queries == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)
|
||||
|
||||
@@ -1869,6 +1869,319 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
|
||||
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
|
||||
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
|
||||
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
|
||||
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
|
||||
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
|
||||
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
|
||||
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
|
||||
}
|
||||
|
||||
func TestValidateScatterPlotDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_scatter_plot_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
softMin, softMax := 0.0, 1000.0
|
||||
size, minSize, maxSize, opacity := PointDiameter(8), PointDiameter(4), PointDiameter(24), PointOpacity(0.1)
|
||||
assert.Equal(t, &ScatterPlotPanelSpec{
|
||||
Visualization: BasicVisualization{TimePreference: TimePreferenceLast1Hr},
|
||||
Dimensions: ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}},
|
||||
Formatting: TableFormatting{ColumnUnits: map[string]string{"A.p99(duration_nano)": "ns"}, DecimalPrecision: PrecisionOption1},
|
||||
Axes: ScatterPlotAxes{
|
||||
X: ScatterPlotAxis{SoftMin: &softMin, Scale: AxisScaleLog, Label: "Throughput"},
|
||||
Y: ScatterPlotAxis{SoftMax: &softMax, Scale: AxisScaleSymlog, Label: "p99 latency"},
|
||||
},
|
||||
ChartAppearance: ScatterPlotChartAppearance{Points: ScatterPlotPoints{Size: &size, MinSize: &minSize, MaxSize: &maxSize, Opacity: &opacity}},
|
||||
Legend: Legend{Position: LegendPositionRight, Mode: LegendModeList, CustomColors: map[string]string{"checkout": "#1f77b4"}},
|
||||
Thresholds: []ScatterPlotThreshold{
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 300, Unit: "ms", Color: "#f00", Label: "p99 SLO"}, Axis: ThresholdAxisY},
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 50, Color: "#0f0"}, Axis: ThresholdAxisX},
|
||||
},
|
||||
}, panelSpec)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeScalar, request.RequestType)
|
||||
require.NotNil(t, request.FormatOptions)
|
||||
assert.True(t, request.FormatOptions.FormatTableResultForUI, "scatter rows are joined the way a table's are")
|
||||
|
||||
require.Len(t, request.CompositeQuery.Queries, 1)
|
||||
query, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
|
||||
require.True(t, ok, "expected a traces builder query")
|
||||
assert.Len(t, query.Aggregations, 3)
|
||||
assert.Len(t, query.GroupBy, 2)
|
||||
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid scalar request")
|
||||
|
||||
// the panel read back out of storage draws the same scatter plot
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
|
||||
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the stored panel's query failed")
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
panelKind string
|
||||
panelSpec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "unknown axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
|
||||
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
|
||||
},
|
||||
{
|
||||
scenario: "non-string axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"y": {"scale": true}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal bool",
|
||||
},
|
||||
{
|
||||
scenario: "isLogScale on a scatter plot axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "single-axis axes shape on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"softMin": 0}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "unknown threshold axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 1, "color": "#f00", "axis": "z"}]}`,
|
||||
expectedErrorSubstring: "invalid threshold axis \"z\": must be `x` or `y`",
|
||||
},
|
||||
{
|
||||
scenario: "unknown dimension",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"z": "A"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "point size below the minimum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 1: must be a whole number between 2 and 40",
|
||||
},
|
||||
{
|
||||
scenario: "max point size above the maximum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 41",
|
||||
},
|
||||
{
|
||||
scenario: "fractional point size",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal number 4.5",
|
||||
},
|
||||
{
|
||||
scenario: "min point size above max",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
|
||||
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity below the floor",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity as a percentage",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 70",
|
||||
},
|
||||
{
|
||||
scenario: "area fill fields on a scatter plot",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "a single colour key as a string",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
scenario: "panel-wide unit on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"formatting": {"unit": "ms"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "threshold without a color",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 300}]}`,
|
||||
expectedErrorSubstring: "Color' Error:Field validation for 'Color' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
scenario: "scale on a time series panel",
|
||||
panelKind: "signoz/TimeSeriesPanel",
|
||||
panelSpec: `{"axes": {"scale": "log"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected the spec to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
queryPlugin string
|
||||
dimensions string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "a single-aggregation query is one value column keyed by its name",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"x": "A", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "a multi-aggregation query keys each value column by name and expression",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}]}}`,
|
||||
dimensions: `{"x": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.x: \"A\" is not a value column of the panel's query; value columns: `A.count()`, `A.p99(duration_nano)`",
|
||||
},
|
||||
{
|
||||
scenario: "a value column of a query the panel does not have",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}`,
|
||||
dimensions: `{"y": "B"}`,
|
||||
expectedErrorSubstring: "dimensions.y: \"B\" is not a value column of the panel's query; value columns: `A`",
|
||||
},
|
||||
{
|
||||
scenario: "a colour key the query does not group by",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"colorBy": ["service.name", "k8s.pod.name"]}`,
|
||||
expectedErrorSubstring: "dimensions.colorBy: \"k8s.pod.name\" is not a group-by key of the panel's query; group-by keys: `service.name`",
|
||||
},
|
||||
{
|
||||
scenario: "a formula is a value column and carries the labels of the disabled queries it reads",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": true, "aggregations": [{"expression": "countIf(has_error = true)"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "B / A"}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "F1", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "a disabled query yields no value column",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": true, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
|
||||
]}}`,
|
||||
dimensions: `{"sizeBy": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.sizeBy: \"A\" is not a value column of the panel's query; value columns: `F1`",
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
|
||||
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`,
|
||||
dimensions: `{"x": "throughput", "colorBy": ["pod"]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/ScatterPlotPanel", "spec": {"dimensions": ` + test.dimensions + `}},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": ` + test.queryPlugin + `}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
if test.expectedErrorSubstring == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err, "expected the binding to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
@@ -2190,7 +2503,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
@@ -2232,14 +2545,17 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
{"TimeSeries+PromQL", mkQuery("signoz/TimeSeriesPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), false},
|
||||
{"Table+ClickHouse", mkQuery("signoz/TablePanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
|
||||
{"ScatterPlot+ClickHouse", mkQuery("signoz/ScatterPlotPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
// Top-level: rejected
|
||||
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+PromQL", mkQuery("signoz/ScatterPlotPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
|
||||
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
|
||||
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
|
||||
// Composite sub-queries
|
||||
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
}
|
||||
|
||||
|
||||
@@ -28,16 +28,17 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,16 +229,17 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -252,16 +255,17 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -209,34 +209,38 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTable},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
|
||||
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: kind},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -166,20 +168,21 @@ func (BuilderQuerySpec) JSONSchemaOneOf() []any {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -329,6 +332,95 @@ type TextPanelSpec struct {
|
||||
HeaderOptions HeaderOptions `json:"headerOptions"`
|
||||
}
|
||||
|
||||
type ScatterPlotPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Dimensions ScatterPlotDimensions `json:"dimensions"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Axes ScatterPlotAxes `json:"axes"`
|
||||
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ScatterPlotThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) error {
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.dimensions: %s", path, err.Error())
|
||||
}
|
||||
keys, known := composite.CollectResultColumnKeys()
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
|
||||
// Empty is unset: the renderer picks a column or a fixed size.
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Aggregations[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
|
||||
}
|
||||
}
|
||||
for _, label := range s.Dimensions.ColorBy {
|
||||
if _, ok := keys.Groups[label]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.colorBy: %q is not a group-by key of the panel's query; group-by keys: %s", path, label, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
|
||||
// use the Table column key, so formatting.columnUnits carries over between the
|
||||
// two kinds.
|
||||
type ScatterPlotDimensions struct {
|
||||
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
|
||||
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
|
||||
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
|
||||
ColorBy []string `json:"colorBy" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
|
||||
}
|
||||
|
||||
type ScatterPlotAxes struct {
|
||||
X ScatterPlotAxis `json:"x"`
|
||||
Y ScatterPlotAxis `json:"y"`
|
||||
}
|
||||
|
||||
type ScatterPlotAxis struct {
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
Scale AxisScale `json:"scale"`
|
||||
Label string `json:"label" description:"Axis title. Empty draws none."`
|
||||
}
|
||||
|
||||
type ScatterPlotChartAppearance struct {
|
||||
Points ScatterPlotPoints `json:"points"`
|
||||
}
|
||||
|
||||
// ScatterPlotPoints keeps the fixed size and the size range side by side so
|
||||
// binding or unbinding dimensions.sizeBy restores the other's last setting. Nil
|
||||
// fields resolve to the renderer default.
|
||||
type ScatterPlotPoints struct {
|
||||
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.sizeBy is unset."`
|
||||
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.sizeBy is set."`
|
||||
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.sizeBy is set."`
|
||||
Opacity *PointOpacity `json:"opacity"`
|
||||
}
|
||||
|
||||
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
|
||||
type alias ScatterPlotPoints
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
|
||||
}
|
||||
*p = ScatterPlotPoints(tmp)
|
||||
return p.validate()
|
||||
}
|
||||
|
||||
func (p ScatterPlotPoints) validate() error {
|
||||
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
@@ -410,6 +502,11 @@ type ComparisonThreshold struct {
|
||||
Format ThresholdFormat `json:"format"`
|
||||
}
|
||||
|
||||
type ScatterPlotThreshold struct {
|
||||
ThresholdWithLabel
|
||||
Axis ThresholdAxis `json:"axis"`
|
||||
}
|
||||
|
||||
type TableThreshold struct {
|
||||
ComparisonThreshold
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true"`
|
||||
@@ -695,6 +792,86 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
|
||||
// defined at and below zero.
|
||||
type AxisScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
|
||||
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
|
||||
AxisScaleLog = AxisScale{valuer.NewString("log")}
|
||||
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (AxisScale) Enum() []any {
|
||||
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
|
||||
}
|
||||
|
||||
func (as AxisScale) ValueOrDefault() string {
|
||||
if as.IsZero() {
|
||||
return AxisScaleAuto.StringValue()
|
||||
}
|
||||
return as.StringValue()
|
||||
}
|
||||
|
||||
func (as AxisScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(as.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (as *AxisScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
val := AxisScale{valuer.NewString(v)}
|
||||
switch val {
|
||||
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
|
||||
*as = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdAxis is the axis a scatter plot threshold line marks: `y` draws it
|
||||
// horizontally, `x` vertically.
|
||||
type ThresholdAxis struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ThresholdAxisX = ThresholdAxis{valuer.NewString("x")}
|
||||
ThresholdAxisY = ThresholdAxis{valuer.NewString("y")} // default
|
||||
)
|
||||
|
||||
func (ThresholdAxis) Enum() []any {
|
||||
return []any{ThresholdAxisX, ThresholdAxisY}
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) ValueOrDefault() string {
|
||||
if ta.IsZero() {
|
||||
return ThresholdAxisY.StringValue()
|
||||
}
|
||||
return ta.StringValue()
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(ta.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (ta *ThresholdAxis) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold axis: must be a string, one of `x` or `y`")
|
||||
}
|
||||
val := ThresholdAxis{valuer.NewString(v)}
|
||||
switch val {
|
||||
case ThresholdAxisX, ThresholdAxisY:
|
||||
*ta = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold axis %q: must be `x` or `y`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -833,6 +1010,53 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
minPointDiameter = 2
|
||||
maxPointDiameter = 40
|
||||
)
|
||||
|
||||
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
|
||||
type PointDiameter int
|
||||
|
||||
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
|
||||
var v int
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point diameter: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
if v < minPointDiameter || v > maxPointDiameter {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point diameter %d: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
*d = PointDiameter(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPointOpacity = 0.1
|
||||
|
||||
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
|
||||
type PointOpacity float64
|
||||
|
||||
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointOpacity).WithMaximum(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
|
||||
var v float64
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
|
||||
}
|
||||
if v < minPointOpacity || v > 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
|
||||
}
|
||||
*o = PointOpacity(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
|
||||
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "service health",
|
||||
"description": "throughput against tail latency per pod"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "throughput vs p99",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "last_1_hr"
|
||||
},
|
||||
"dimensions": {
|
||||
"x": "A.count()",
|
||||
"y": "A.p99(duration_nano)",
|
||||
"sizeBy": "A.countIf(has_error = true)",
|
||||
"colorBy": ["k8s.namespace.name", "k8s.pod.name"]
|
||||
},
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A.p99(duration_nano)": "ns"
|
||||
},
|
||||
"decimalPrecision": "1"
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"softMin": 0,
|
||||
"softMax": null,
|
||||
"scale": "log",
|
||||
"label": "Throughput"
|
||||
},
|
||||
"y": {
|
||||
"softMin": null,
|
||||
"softMax": 1000,
|
||||
"scale": "symlog",
|
||||
"label": "p99 latency"
|
||||
}
|
||||
},
|
||||
"chartAppearance": {
|
||||
"points": {
|
||||
"size": 8,
|
||||
"minSize": 4,
|
||||
"maxSize": 24,
|
||||
"opacity": 0.1
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": {
|
||||
"checkout": "#1f77b4"
|
||||
}
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 300,
|
||||
"unit": "ms",
|
||||
"color": "#f00",
|
||||
"label": "p99 SLO",
|
||||
"axis": "y"
|
||||
},
|
||||
{
|
||||
"value": 50,
|
||||
"color": "#0f0",
|
||||
"axis": "x"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
},
|
||||
{
|
||||
"expression": "p99(duration_nano)"
|
||||
},
|
||||
{
|
||||
"expression": "countIf(has_error = true)"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "k8s.namespace.name"
|
||||
},
|
||||
{
|
||||
"name": "k8s.pod.name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -266,6 +266,43 @@ type CompositeQuery struct {
|
||||
Queries []QueryEnvelope `json:"queries"`
|
||||
}
|
||||
|
||||
// ResultColumnKeys is what a scalar query yields per row, keyed the way the
|
||||
// UI keys columns: an aggregation column is the query name, or
|
||||
// queryName.expression when the query has several aggregations; a group
|
||||
// column is a group-by key name.
|
||||
type ResultColumnKeys struct {
|
||||
Aggregations map[string]struct{}
|
||||
Groups map[string]struct{}
|
||||
}
|
||||
|
||||
// CollectResultColumnKeys reports false when a query's columns are only known
|
||||
// at run time, as for ClickHouse SQL and PromQL. A disabled query yields no
|
||||
// aggregation column but keeps its group columns, since a formula over it
|
||||
// still carries them.
|
||||
func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
|
||||
keys := ResultColumnKeys{Aggregations: map[string]struct{}{}, Groups: map[string]struct{}{}}
|
||||
for _, envelope := range c.Queries {
|
||||
if envelope.GetType() == QueryTypeClickHouseSQL || envelope.GetType() == QueryTypePromQL {
|
||||
return ResultColumnKeys{}, false
|
||||
}
|
||||
for _, key := range envelope.GetGroupBy() {
|
||||
keys.Groups[key.Name] = struct{}{}
|
||||
}
|
||||
if envelope.IsDisabled() {
|
||||
continue
|
||||
}
|
||||
expressions := envelope.GetAggregationExpressions()
|
||||
if len(expressions) <= 1 {
|
||||
keys.Aggregations[envelope.GetQueryName()] = struct{}{}
|
||||
continue
|
||||
}
|
||||
for _, expression := range expressions {
|
||||
keys.Aggregations[envelope.GetQueryName()+"."+expression] = struct{}{}
|
||||
}
|
||||
}
|
||||
return keys, true
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the CompositeQuery schema.
|
||||
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")
|
||||
|
||||
@@ -225,6 +225,34 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAggregationExpressions returns one entry per aggregation. A metric
|
||||
// aggregation has no expression, so it contributes an empty entry.
|
||||
func (q *QueryEnvelope) GetAggregationExpressions() []string {
|
||||
switch spec := q.Spec.(type) {
|
||||
case QueryBuilderTraceOperator:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
return make([]string, len(spec.Aggregations))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFilter returns the filter.
|
||||
func (q *QueryEnvelope) GetFilter() *Filter {
|
||||
switch spec := q.Spec.(type) {
|
||||
|
||||
@@ -1903,3 +1903,63 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
composite CompositeQuery
|
||||
expectedKeys ResultColumnKeys
|
||||
expectedKnown bool
|
||||
}{
|
||||
{
|
||||
scenario: "a single aggregation is keyed by the query name",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[TraceAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []TraceAggregation{{Expression: "count()"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "several aggregations are keyed by query name and expression",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []LogAggregation{{Expression: "count()"}, {Expression: "p99(duration_nano)"}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A.count()": {}, "A.p99(duration_nano)": {}}, Groups: map[string]struct{}{}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "a disabled query yields no aggregation but keeps its groups for the formula over it",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{
|
||||
{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Aggregations: []MetricAggregation{{MetricName: "http.server.request.count"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}},
|
||||
{Type: QueryTypeFormula, Spec: QueryBuilderFormula{Name: "F1", Expression: "A * 2"}},
|
||||
}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"F1": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeClickHouseSQL, Spec: ClickHouseQuery{
|
||||
Name: "A",
|
||||
Query: "SELECT 1",
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{},
|
||||
expectedKnown: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
keys, known := testCase.composite.CollectResultColumnKeys()
|
||||
assert.Equal(t, testCase.expectedKnown, known)
|
||||
assert.Equal(t, testCase.expectedKeys, keys)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user