Compare commits

..

1 Commits

Author SHA1 Message Date
Jatinderjit Singh
6ffbb1ddb8 feat(rules): extract recent log samples for log-based alerts
Log-based alerts currently attach only a link to the related logs, so a
responder has to navigate to the explorer to see what fired the alert.
This extracts the most recent matching log lines during rule evaluation
and stores them in a new public `related_logs_samples` annotation,
alongside the existing `related_logs` link.

- Refactor the link's filter computation into a shared `logsQueryParams`
  helper so the samples query reuses the exact same per-group where
  clause as the related-logs link (samples == the logs the link opens).
- `fetchLogSamples` issues a RequestTypeRaw query (timestamp,id DESC,
  limit 5) for the breaching group. It is best-effort: any failure is
  logged and yields no samples rather than failing evaluation.
- `formatLogSamples` renders a compact, code-fenced block (one line per
  record), skipping empty bodies and truncating each to 512 runes.
- Skipped for no-data alerts.

Notification-channel rendering (email/Slack) is intentionally out of
scope here; the annotation already flows to webhook and custom
templates as $related_logs_samples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:22:38 +05:30
26 changed files with 429 additions and 1072 deletions

View File

@@ -64,16 +64,16 @@ web:
settings:
posthog:
# Whether to enable PostHog in web.
enabled: false
enabled: true
appcues:
# Whether to enable Appcues in web.
enabled: false
enabled: true
sentry:
# Whether to enable Sentry in web.
enabled: false
enabled: true
pylon:
# Whether to enable Pylon in web.
enabled: false
enabled: true
##################### Cache #####################
cache:

View File

@@ -6572,15 +6572,6 @@ components:
nullable: true
type: array
type: object
SpantypesOtelSpanRef:
properties:
refType:
type: string
spanId:
type: string
traceId:
type: string
type: object
SpantypesPostableSpanMapper:
properties:
config:
@@ -6844,10 +6835,6 @@ components:
type: string
parent_span_id:
type: string
references:
items:
$ref: '#/components/schemas/SpantypesOtelSpanRef'
type: array
resource:
additionalProperties:
type: string
@@ -6873,8 +6860,6 @@ components:
type: string
trace_state:
type: string
required:
- references
type: object
TagtypesPostableTag:
properties:

View File

@@ -7768,21 +7768,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
aggregations: SpantypesSpanAggregationResultDTO[];
}
export interface SpantypesOtelSpanRefDTO {
/**
* @type string
*/
refType?: string;
/**
* @type string
*/
spanId?: string;
/**
* @type string
*/
traceId?: string;
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
@@ -7877,10 +7862,6 @@ export interface SpantypesWaterfallSpanDTO {
* @type string
*/
parent_span_id?: string;
/**
* @type array
*/
references: SpantypesOtelSpanRefDTO[];
/**
* @type object,null
*/

View File

@@ -22,7 +22,6 @@ import styles from './AnalyticsPanel.module.scss';
interface AnalyticsPanelProps {
isOpen: boolean;
onClose: () => void;
onTabChange: (tab: string) => void;
}
const PANEL_WIDTH = 350;
@@ -33,7 +32,6 @@ const PANEL_MARGIN_BOTTOM = 50;
function AnalyticsPanel({
isOpen,
onClose,
onTabChange,
}: AnalyticsPanelProps): JSX.Element | null {
const aggregations = useTraceStore((s) => s.aggregations);
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
@@ -120,7 +118,7 @@ function AnalyticsPanel({
/>
<div className={styles.body}>
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
<TabsRoot defaultValue="exec-time">
<TabsList variant="secondary">
<TabsTrigger value="exec-time" variant="secondary">
% exec time

View File

@@ -31,12 +31,7 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import {
TraceDetailEventKeys,
TraceDetailEvents,
} from 'pages/TraceDetailsV3/events';
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
import { useTraceDetailLogEvent } from 'pages/TraceDetailsV3/hooks/useTraceDetailLogEvent';
import {
getSpanAttribute,
getSpanDisplayData,
@@ -91,16 +86,6 @@ function SpanDetailsContent({
}): JSX.Element {
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
const spanAttributeActions = useSpanAttributeActions();
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
const handleTabChange = useCallback(
(tab: string): void => {
logTraceEvent(TraceDetailEvents.SpanPanelTabChanged, {
[TraceDetailEventKeys.Tab]: tab,
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
});
},
[logTraceEvent, selectedSpan.span_id],
);
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
@@ -391,7 +376,7 @@ function SpanDetailsContent({
<div className={styles.tabsSection}>
{/* Step 9: ContentTabs */}
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
<TabsRoot defaultValue="overview">
<TabsList variant="secondary">
<TabsTrigger value="overview" variant="secondary">
<Bookmark size={14} /> Overview

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import {
@@ -29,8 +29,6 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceStore } from '../stores/traceStore';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
@@ -92,35 +90,11 @@ function TraceDetailsHeader({
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
const logTraceEvent = useTraceDetailLogEvent('v3', traceID || '');
const pageLoadedAtRef = useRef(Date.now());
const handleSwitchToOldView = useCallback((): void => {
logTraceEvent(TraceDetailEvents.ViewSwitched, {
[TraceDetailEventKeys.From]: 'v3',
[TraceDetailEventKeys.To]: 'v2',
[TraceDetailEventKeys.DwellMs]: Date.now() - pageLoadedAtRef.current,
});
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
history.replace(oldUrl);
}, [traceID, logTraceEvent]);
const handleToggleAnalytics = useCallback((): void => {
logTraceEvent(TraceDetailEvents.AnalyticsPanelToggled, {
[TraceDetailEventKeys.Open]: !isAnalyticsOpen,
});
setIsAnalyticsOpen((prev) => !prev);
}, [logTraceEvent, isAnalyticsOpen]);
const handleAnalyticsTabChange = useCallback(
(tab: string): void => {
logTraceEvent(TraceDetailEvents.AnalyticsTabChanged, {
[TraceDetailEventKeys.Tab]: tab,
});
},
[logTraceEvent],
);
}, [traceID]);
const handlePreviousBtnClick = useCallback((): void => {
if (hasInAppHistory()) {
@@ -193,7 +167,7 @@ function TraceDetailsHeader({
size="icon"
color="secondary"
aria-label="Analytics"
onClick={handleToggleAnalytics}
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
<ChartPie size={14} />
</Button>
@@ -271,7 +245,6 @@ function TraceDetailsHeader({
<AnalyticsPanel
isOpen={isAnalyticsOpen}
onClose={(): void => setIsAnalyticsOpen(false)}
onTabChange={handleAnalyticsTabChange}
/>
</div>
);

View File

@@ -472,98 +472,4 @@ describe('computeVisualLayout', () => {
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
});
// --- Wide-group fast path (> WIDE_GROUP_THRESHOLD siblings) ---
// Past the threshold the layout switches to exact overlap-only packing to
// avoid the O(N^2) connector-avoidance spiral. These lock in correctness and
// the no-overlap invariant at scale.
function noRowHasOverlap(
layout: ReturnType<typeof computeVisualLayout>,
): void {
for (const row of layout.visualRows) {
const sorted = [...row].sort((a, b) => a.timestamp - b.timestamp);
for (let i = 1; i < sorted.length; i++) {
const prevEnd = sorted[i - 1].timestamp + sorted[i - 1].durationNano / 1e6;
expect(sorted[i].timestamp).toBeGreaterThanOrEqual(prevEnd);
}
}
}
it('should pack thousands of sequential leaf siblings into 1 row (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
// 2000 strictly sequential (non-overlapping) children
for (let i = 0; i < 2000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * 10,
durationNano: 5e6, // 5ms, ends before next starts
}),
);
}
const layout = computeVisualLayout([[root], kids]);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.totalVisualRows).toBe(2); // all siblings share row 1
for (const k of kids) {
expect(layout.spanToVisualRow[k.spanId]).toBe(1);
}
noRowHasOverlap(layout);
});
it('should pack thousands of fully-overlapping leaf siblings without violations (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
// 1000 children all spanning the same window → each needs its own row
for (let i = 0; i < 1000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6,
}),
);
}
const layout = computeVisualLayout([[root], kids]);
expect(layout.totalVisualRows).toBe(1001); // root + 1000 stacked rows
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1001);
noRowHasOverlap(layout);
});
it('should keep non-leaf subtrees adjacent within a wide mixed group (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
for (let i = 0; i < 1000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * 10,
durationNano: 5e6,
}),
);
}
// One of the wide siblings has a child of its own
const grandchild = makeSpan({
spanId: 'gc',
parentSpanId: 'k500',
timestamp: 5000,
durationNano: 2e6,
});
const layout = computeVisualLayout([[root], kids, [grandchild]]);
const parentRow = layout.spanToVisualRow['k500'];
const gcRow = layout.spanToVisualRow['gc'];
expect(gcRow - parentRow).toBe(1); // subtree adjacency preserved
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1002);
noRowHasOverlap(layout);
});
});

View File

@@ -18,81 +18,6 @@ export interface VisualLayout {
totalVisualRows: number;
}
// Above this many siblings under one parent, the connector-avoidance refinement
// (Checks 2 & 3) is both visually meaningless — the row is already a dense wall —
// and quadratic: every child deposits a connector point on each intermediate row,
// which pushes later children even higher, which deposits more points. That
// feedback loop inflates a layout needing ~50 rows to thousands and never
// finishes on wide traces. Past the threshold we pack by overlap only.
const WIDE_GROUP_THRESHOLD = 512;
/**
* Segment tree over rows that answers "lowest row index >= `from` whose smallest
* span start-time is >= `end`" in O(log rows). Used to place a large group of
* leaf siblings by overlap only: because siblings are processed in descending
* start order, every already-placed span on a row starts at or after the current
* one, so [start, end] overlaps a row iff some span there starts before `end` —
* i.e. the row is free iff its minimum start >= end. Each node stores the max of
* its subtree's per-row minimum starts so a free row can be found by descent.
*/
class LowestFreeRow {
private readonly size: number;
private readonly tree: Float64Array;
constructor(rows: number) {
let size = 1;
while (size < rows) {
size *= 2;
}
this.size = size;
this.tree = new Float64Array(size * 2).fill(Infinity);
}
place(row: number, start: number): void {
let i = row + this.size;
// A row's key is the minimum start among its spans. Children are processed
// in descending start order so a leaf's start is the new minimum, but a
// non-leaf subtree's descendant can land on a row out of order — take min.
if (start >= this.tree[i]) {
return;
}
this.tree[i] = start;
for (i >>= 1; i >= 1; i >>= 1) {
const next = Math.max(this.tree[2 * i], this.tree[2 * i + 1]);
if (this.tree[i] === next) {
break;
}
this.tree[i] = next;
}
}
lowestFrom(from: number, end: number): number {
return this.descend(1, 0, this.size - 1, from, end);
}
private descend(
node: number,
lo: number,
hi: number,
from: number,
end: number,
): number {
if (hi < from || this.tree[node] < end) {
return -1;
}
if (lo === hi) {
return lo;
}
const mid = (lo + hi) >> 1;
const left = this.descend(2 * node, lo, mid, from, end);
if (left !== -1) {
return left;
}
return this.descend(2 * node + 1, mid + 1, hi, from, end);
}
}
/**
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
*
@@ -289,53 +214,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
arr.push(point);
}
// Fast path for a parent with a very large group of children: pack by overlap
// only (descending greedy), skipping the quadratic connector-avoidance that
// spirals at this scale. Leaf children — the bulk of a wide trace — are placed
// in O(log rows) via the segment tree; the rare non-leaf subtree falls back to
// findPlacement against the shared interval map. Both structures are kept in
// sync so each placement sees all prior occupancy. Same ShapeEntry[] contract.
function computeWideShape(
rootSpan: FlamegraphSpan,
children: FlamegraphSpan[],
): ShapeEntry[] {
const shape: ShapeEntry[] = [{ span: rootSpan, relativeRow: 0 }];
const localIntervals = new Map<number, Array<[number, number]>>();
// Children occupy relative rows 1..children.length in the worst case.
const finder = new LowestFreeRow(children.length + 2);
const occupy = (row: number, span: FlamegraphSpan): void => {
const s = span.timestamp;
const e = span.timestamp + span.durationNano / 1e6;
shape.push({ span, relativeRow: row });
addIntervalTo(localIntervals, row, s, e);
finder.place(row, s);
};
for (const child of children) {
if (childrenMap.has(child.spanId)) {
// Non-leaf: place its whole subtree shape as a unit via findPlacement.
const childShape = computeSubtreeShape(child);
const offset = findPlacement(childShape, 1, localIntervals);
for (const entry of childShape) {
occupy(entry.relativeRow + offset, entry.span);
}
} else {
const end = child.timestamp + child.durationNano / 1e6;
occupy(finder.lowestFrom(1, end), child);
}
}
return shape;
}
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
const children = childrenMap.get(rootSpan.spanId);
if (children && children.length > WIDE_GROUP_THRESHOLD) {
return computeWideShape(rootSpan, children);
}
const localIntervals = new Map<number, Array<[number, number]>>();
const localConnectorPoints = new Map<number, number[]>();
const shape: ShapeEntry[] = [];
@@ -346,6 +225,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
shape.push({ span: rootSpan, relativeRow: 0 });
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
const children = childrenMap.get(rootSpan.spanId);
if (children) {
for (const child of children) {
const childShape = computeSubtreeShape(child);

View File

@@ -94,7 +94,7 @@ export function useVisualLayoutWorker(spans: FlamegraphSpan[][]): {
cleanup();
};
// Timeout: if worker doesn't respond in 15s, terminate and error
// Timeout: if worker doesn't respond in 30s, terminate and error
const WORKER_TIMEOUT_MS = 15000;
const timeoutId = setTimeout(() => {
if (requestIdRef.current === currentId && isComputingRef.current) {

View File

@@ -1,38 +0,0 @@
export enum TraceDetailEvents {
DataLoaded = 'Trace Detail: Data loaded',
ViewSwitched = 'Trace Detail: View switched',
FlameGraphToggled = 'Trace Detail: Flame graph toggled',
WaterfallToggled = 'Trace Detail: Waterfall toggled',
AnalyticsPanelToggled = 'Trace Detail: Analytics panel toggled',
AnalyticsTabChanged = 'Trace Detail: Analytics tab changed',
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
}
export enum TraceDetailEventKeys {
// Injected on every event by useTraceDetailLogEvent
View = 'view',
TraceId = 'traceId',
// Data loaded — trace shape
TotalSpansCount = 'totalSpansCount',
NumServices = 'numServices',
TraceDurationMs = 'traceDurationMs',
HadErrors = 'hadErrors',
FlamegraphSampled = 'flamegraphSampled',
// Data loaded — persisted settings
SpanPanelVariant = 'spanPanelVariant',
ColorByField = 'colorByField',
PreviewFieldsCount = 'previewFieldsCount',
EntryPreferOldView = 'entryPreferOldView',
// View switched
From = 'from',
To = 'to',
DwellMs = 'dwellMs',
// Toggles / tabs
Expanded = 'expanded',
Open = 'open',
Tab = 'tab',
// Span panel tab changed
SpanId = 'spanId',
}
export type TraceDetailView = 'v2' | 'v3';

View File

@@ -1,88 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { TraceDetailEvents } from '../../events';
import { useTraceDetailLogEvent } from '../useTraceDetailLogEvent';
const logEventMock = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): void => logEventMock(...args),
}));
describe('useTraceDetailLogEvent', () => {
beforeEach(() => {
logEventMock.mockClear();
});
it('injects view and traceId on every event', () => {
const { result } = renderHook(() =>
useTraceDetailLogEvent('v3', 'trace-123'),
);
act(() => {
result.current(TraceDetailEvents.DataLoaded, { totalSpansCount: 42 });
});
expect(logEventMock).toHaveBeenCalledTimes(1);
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
view: 'v3',
traceId: 'trace-123',
totalSpansCount: 42,
});
});
it('injects view and traceId even when no attributes are passed', () => {
const { result } = renderHook(() =>
useTraceDetailLogEvent('v2', 'trace-456'),
);
act(() => {
result.current(TraceDetailEvents.ViewSwitched);
});
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.ViewSwitched, {
view: 'v2',
traceId: 'trace-456',
});
});
it('keeps a stable callback identity and emits the latest traceId', () => {
const { result, rerender } = renderHook(
({ traceId }) => useTraceDetailLogEvent('v3', traceId),
{ initialProps: { traceId: 'trace-1' } },
);
const firstIdentity = result.current;
rerender({ traceId: 'trace-2' });
expect(result.current).toBe(firstIdentity);
act(() => {
result.current(TraceDetailEvents.SpanPanelTabChanged, { spanId: 's1' });
});
expect(logEventMock).toHaveBeenCalledWith(
TraceDetailEvents.SpanPanelTabChanged,
{
view: 'v3',
traceId: 'trace-2',
spanId: 's1',
},
);
});
it('never throws if logEvent throws (analytics must not break the UI)', () => {
logEventMock.mockImplementationOnce(() => {
throw new Error('network down');
});
const { result } = renderHook(() =>
useTraceDetailLogEvent('v3', 'trace-123'),
);
expect(() => {
act(() => {
result.current(TraceDetailEvents.DataLoaded);
});
}).not.toThrow();
});
});

View File

@@ -1,39 +0,0 @@
import { useCallback, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import {
TraceDetailEventKeys,
TraceDetailEvents,
TraceDetailView,
} from '../events';
export type TraceDetailLogEvent = (
event: TraceDetailEvents,
attributes?: Record<string, unknown>,
) => void;
export function useTraceDetailLogEvent(
view: TraceDetailView,
traceId: string,
): TraceDetailLogEvent {
const contextRef = useRef({ view, traceId });
contextRef.current = { view, traceId };
return useCallback(
(
event: TraceDetailEvents,
attributes: Record<string, unknown> = {},
): void => {
try {
void logEvent(event, {
[TraceDetailEventKeys.View]: contextRef.current.view,
[TraceDetailEventKeys.TraceId]: contextRef.current.traceId,
...attributes,
});
} catch {
// No-op. Logging must never throw into the UI.
}
},
[],
);
}

View File

@@ -20,10 +20,7 @@ import {
} from 'types/api/trace/getTraceV3';
import { COLOR_BY_FIELDS } from './constants';
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { AGGREGATIONS } from './utils/aggregations';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
@@ -59,14 +56,6 @@ function TraceDetailsV3(): JSX.Element {
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef('');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() });
@@ -165,46 +154,6 @@ function TraceDetailsV3(): JSX.Element {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
[TraceDetailEventKeys.EntryPreferOldView]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true',
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
@@ -284,12 +233,6 @@ function TraceDetailsV3(): JSX.Element {
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);

View File

@@ -4,7 +4,6 @@ import { ChevronDown, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import { JsonView } from 'periscope/components/JsonView';
import { PrettyView } from 'periscope/components/PrettyView';
import { PrettyViewProps } from 'periscope/components/PrettyView';
@@ -13,8 +12,6 @@ import './DataViewer.styles.scss';
type ViewMode = 'pretty' | 'json';
const VIEW_MODE_CHANGED_EVENT = 'Data Viewer: View mode changed';
const VIEW_MODE_OPTIONS: { label: string; value: ViewMode }[] = [
{ label: 'Pretty', value: 'pretty' },
{ label: 'JSON', value: 'json' },
@@ -37,20 +34,6 @@ function DataViewer({
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
const handleViewModeChange = (value: string): void => {
const next = value as ViewMode;
setViewMode(next);
try {
logEvent(VIEW_MODE_CHANGED_EVENT, {
viewMode: next,
path: window.location.pathname,
drawerKey,
});
} catch {
// No op
}
};
const handleCopy = (): void => {
const text = JSON.stringify(data, null, 2);
setCopy(text);
@@ -73,7 +56,7 @@ function DataViewer({
{
type: 'radio-group',
value: viewMode,
onChange: handleViewModeChange,
onChange: (value): void => setViewMode(value as ViewMode),
children: VIEW_MODE_OPTIONS.map((opt) => ({
type: 'radio',
key: opt.value,

View File

@@ -74,7 +74,7 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
events, status_message, status_code_string, kind_string, parent_span_id,
flags, is_remote, trace_state, status_code,
db_name, db_operation, http_method, http_url, http_host,
external_http_method, external_http_url, response_status_code, links as references
external_http_method, external_http_url, response_status_code
FROM %s.%s
WHERE trace_id=? AND ts_bucket_start>=? AND ts_bucket_start<=?
ORDER BY timestamp ASC, name ASC`,
@@ -130,7 +130,7 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
"flags", "is_remote", "trace_state", "status_code",
"db_name", "db_operation", "http_method", "http_url", "http_host",
"external_http_method", "external_http_url", "response_status_code", "links as references",
"external_http_method", "external_http_url", "response_status_code",
)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
ids := make([]any, len(spanIDs))

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/url"
"reflect"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/contextlinks"
@@ -85,19 +86,35 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
return req, nil
}
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
// logSamples* bound the recent log lines we attach to a firing log-based alert.
// They are kept small to bound the notification payload size.
const (
// logSamplesMaxCount is the number of most-recent matching log records sampled.
logSamplesMaxCount = 5
// logSampleBodyMaxLen truncates each sampled body (in runes) so a single large
// record cannot blow up the annotation/notification.
logSampleBodyMaxLen = 512
)
// logsQueryParams extracts, for a log-based alert, the evaluation window and the
// per-group where clause: the rule's filter combined with the breaching group's
// label values (lbls). The same where clause backs both the related-logs link and
// the sample-logs query, so they always refer to the same set of logs. ok is false
// when the rule is not a single log builder query (e.g. a formula or non-logs
// signal), in which case there is nothing to link to or sample.
func (r *ThresholdRule) logsQueryParams(ctx context.Context, ts time.Time, lbls ruletypes.Labels) (start, end time.Time, whereClause string, ok bool) {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
return time.Time{}, time.Time{}, "", false
}
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
start = time.UnixMilli(int64(qr.Start))
end = time.UnixMilli(int64(qr.End))
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
return nil
return time.Time{}, time.Time{}, "", false
}
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
@@ -112,7 +129,7 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
}
if q.Signal != telemetrytypes.SignalLogs {
return nil
return time.Time{}, time.Time{}, "", false
}
filterExpr := ""
@@ -120,11 +137,153 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
whereClause = contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
return start, end, whereClause, true
}
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
if !ok {
return nil
}
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
}
// fetchLogSamples returns up to logSamplesMaxCount of the most-recent log records
// matching the alert's filter for the breaching group (lbls), newest first. It
// reuses the same where clause as the related-logs link, so the samples are exactly
// the logs that link points to.
//
// Sampling is best-effort enrichment: any failure is logged and yields no samples
// rather than failing the rule evaluation.
func (r *ThresholdRule) fetchLogSamples(ctx context.Context, ts time.Time, lbls ruletypes.Labels) []*qbtypes.RawRow {
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
if !ok {
return nil
}
req := &qbtypes.QueryRangeRequest{
Start: uint64(start.UnixMilli()),
End: uint64(end.UnixMilli()),
RequestType: qbtypes.RequestTypeRaw,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Name: "log_samples",
Filter: &qbtypes.Filter{Expression: whereClause},
Limit: logSamplesMaxCount,
// timestamp,id DESC => most recent first. Both keys with an
// identical direction are also what enables the window-list
// fast path for raw log queries.
Order: []qbtypes.OrderBy{
{
Direction: qbtypes.OrderDirectionDesc,
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp", Materialized: true}},
},
{
Direction: qbtypes.OrderDirectionDesc,
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "id", Materialized: true}},
},
},
},
},
},
},
NoCache: true,
}
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "rules",
instrumentationtypes.CodeFunctionName: "fetchLogSamples",
})
resp, err := r.querier.QueryRange(ctx, r.orgID, req)
if err != nil {
r.logger.WarnContext(ctx, "failed to fetch log samples for alert annotation", errors.Attr(err))
return nil
}
for _, item := range resp.Data.Results {
if raw, ok := item.(*qbtypes.RawData); ok {
return raw.Rows
}
}
return nil
}
// formatLogSamples renders sampled log records as a compact, monospaced markdown
// block: one line per record as "[RFC3339] SEVERITY body". Records without a body
// are skipped (mirroring Datadog), each body is collapsed to a single line and
// truncated to logSampleBodyMaxLen. Returns "" when there is nothing to show.
func formatLogSamples(rows []*qbtypes.RawRow) string {
lines := make([]string, 0, len(rows))
for _, row := range rows {
if row == nil {
continue
}
body := strings.TrimSpace(rawRowStringField(row, "body"))
if body == "" {
continue
}
body = truncateRunes(logSampleSingleLine(body), logSampleBodyMaxLen)
var sb strings.Builder
if !row.Timestamp.IsZero() {
sb.WriteString("[")
sb.WriteString(row.Timestamp.UTC().Format(time.RFC3339))
sb.WriteString("] ")
}
if sev := strings.TrimSpace(rawRowStringField(row, "severity_text")); sev != "" {
sb.WriteString(sev)
sb.WriteString(" ")
}
sb.WriteString(body)
lines = append(lines, sb.String())
}
if len(lines) == 0 {
return ""
}
return "```\n" + strings.Join(lines, "\n") + "\n```"
}
// rawRowStringField returns the named field from a raw row as a string, or "" if it
// is absent or not a string.
func rawRowStringField(row *qbtypes.RawRow, key string) string {
if row == nil || row.Data == nil {
return ""
}
if v, ok := row.Data[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// logSampleSingleLine collapses newlines so a multi-line log body renders as one
// line within the samples block.
func logSampleSingleLine(s string) string {
replacer := strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ")
return replacer.Replace(s)
}
// truncateRunes shortens s to at most max runes, appending an ellipsis when trimmed.
func truncateRunes(s string, max int) string {
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max]) + "…"
}
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
selectedQuery := r.SelectedQuery(ctx)
@@ -352,6 +511,14 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
}
// Attach a few recent matching log lines so responders see what fired
// the alert without leaving the notification. Skipped for no-data
// alerts, which by definition have no matching logs.
if !smpl.IsMissing {
if samples := formatLogSamples(r.fetchLogSamples(ctx, ts, smpl.Metric)); samples != "" {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogsSamples, Value: samples})
}
}
}
lbs := lb.Labels()

View File

@@ -0,0 +1,72 @@
package rules
import (
"strings"
"testing"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
)
func TestFormatLogSamples(t *testing.T) {
ts := time.Date(2026, time.June, 1, 12, 0, 3, 0, time.UTC)
t.Run("nil and empty yield empty string", func(t *testing.T) {
assert.Equal(t, "", formatLogSamples(nil))
assert.Equal(t, "", formatLogSamples([]*qbtypes.RawRow{}))
})
t.Run("skips nil rows and rows without a usable body", func(t *testing.T) {
rows := []*qbtypes.RawRow{
nil,
{Timestamp: ts, Data: map[string]any{"body": ""}},
{Timestamp: ts, Data: map[string]any{"body": " "}},
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR"}}, // no body key
{Timestamp: ts, Data: map[string]any{"body": 42}}, // body not a string
}
assert.Equal(t, "", formatLogSamples(rows))
})
t.Run("renders timestamp, severity and body inside a code block", func(t *testing.T) {
rows := []*qbtypes.RawRow{
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR", "body": "payment failed"}},
}
want := "```\n[2026-06-01T12:00:03Z] ERROR payment failed\n```"
assert.Equal(t, want, formatLogSamples(rows))
})
t.Run("omits severity when absent and collapses a multi-line body", func(t *testing.T) {
rows := []*qbtypes.RawRow{
{Timestamp: ts, Data: map[string]any{"body": "line1\nline2\r\nline3"}},
}
want := "```\n[2026-06-01T12:00:03Z] line1 line2 line3\n```"
assert.Equal(t, want, formatLogSamples(rows))
})
t.Run("omits the timestamp prefix when zero", func(t *testing.T) {
rows := []*qbtypes.RawRow{
{Data: map[string]any{"body": "no ts"}},
}
assert.Equal(t, "```\nno ts\n```", formatLogSamples(rows))
})
t.Run("renders one line per record and preserves input order", func(t *testing.T) {
rows := []*qbtypes.RawRow{
{Timestamp: ts, Data: map[string]any{"body": "first"}},
{Timestamp: ts.Add(-time.Second), Data: map[string]any{"body": "second"}},
}
want := "```\n[2026-06-01T12:00:03Z] first\n[2026-06-01T12:00:02Z] second\n```"
assert.Equal(t, want, formatLogSamples(rows))
})
t.Run("truncates a long body to logSampleBodyMaxLen runes plus ellipsis", func(t *testing.T) {
long := strings.Repeat("a", logSampleBodyMaxLen+50)
rows := []*qbtypes.RawRow{
{Timestamp: ts, Data: map[string]any{"body": long}},
}
out := formatLogSamples(rows)
assert.Contains(t, out, strings.Repeat("a", logSampleBodyMaxLen)+"…")
assert.NotContains(t, out, strings.Repeat("a", logSampleBodyMaxLen+1))
})
}

View File

@@ -124,10 +124,8 @@ func (b *traceQueryStatementBuilder) Build(
-------------------------------- End of tech debt ----------------------------
*/
for _, action := range adjustTraceKeys(keys, &query, requestType) {
// TODO: change to debug level once we are confident about the behavior
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
}
query = b.adjustKeys(ctx, keys, query, requestType)
// Create SQL builder
q := sqlbuilder.NewSelectBuilder()
@@ -195,30 +193,24 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
return keySelectors
}
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
// definitions to the keys map. We do this during statement building, not at
// metadata fetch time, because:
// 1. Filter expressions that reference deprecated columns must continue to
// resolve — otherwise they fail with "key not found".
// 2. Doing it at metadata fetch time would also surface deprecated keys in
// autocomplete suggestions, which we don't want.
// 3. We prepend (not append) so the intrinsic/calculated entry wins ordering
// in the multi_if SQL expression.
func mergeDeprecatedTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey) {
func (b *traceQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] {
// add deprecated fields only during statement building
// why?
// 1. to not fail filter expression that use deprecated cols
// 2. this could have been moved to metadata fetching itself, however, that
// would mean, they also show up in suggestions we we don't want to do
// 3. reason for not doing a simple append is to keep intrinsic/calculated field first so that it gets
// priority in multi_if sql expression
for fieldKeyName, fieldKey := range IntrinsicFieldsDeprecated {
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
}
for fieldKeyName, fieldKey := range CalculatedFieldsDeprecated {
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
}
}
func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query *qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) []string {
mergeDeprecatedTraceKeys(keys)
// Adjust keys for alias expressions in aggregations
actions := querybuilder.AdjustKeysForAliasExpressions(query, requestType)
actions := querybuilder.AdjustKeysForAliasExpressions(&query, requestType)
/*
Check if user is using multiple contexts or data types for same field name
@@ -236,7 +228,7 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
and make it just http.status_code and remove the duplicate entry.
*/
actions = append(actions, querybuilder.AdjustDuplicateKeys(query)...)
actions = append(actions, querybuilder.AdjustDuplicateKeys(&query)...)
/*
Now adjust each key to have correct context and data type
@@ -244,20 +236,24 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
Reason for doing this is to not create an unexpected behavior for users
*/
for idx := range query.SelectFields {
actions = append(actions, adjustTraceKey(&query.SelectFields[idx], keys)...)
actions = append(actions, b.adjustKey(&query.SelectFields[idx], keys)...)
}
for idx := range query.GroupBy {
actions = append(actions, adjustTraceKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
actions = append(actions, b.adjustKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
}
for idx := range query.Order {
actions = append(actions, adjustTraceKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
actions = append(actions, b.adjustKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
}
return actions
for _, action := range actions {
// TODO: change to debug level once we are confident about the behavior
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
}
return query
}
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map.
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
func (b *traceQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
// for recording actions taken
actions := []string{}

View File

@@ -1125,13 +1125,28 @@ func TestAdjustKey(t *testing.T) {
},
}
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
nil,
fl,
)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Create a copy of the input key to avoid modifying the original
key := c.inputKey
// Call adjustKey
adjustTraceKey(&key, c.keysMap)
statementBuilder.adjustKey(&key, c.keysMap)
// Verify the key was adjusted as expected
require.Equal(t, c.expectedKey.Name, key.Name, "key name should match")
@@ -1384,6 +1399,21 @@ func TestAdjustKeys(t *testing.T) {
},
}
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
nil,
fl,
)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Create a deep copy of the keys map to avoid modifying the original
@@ -1394,7 +1424,7 @@ func TestAdjustKeys(t *testing.T) {
}
// Call adjustKeys
adjustTraceKeys(keysMapCopy, &c.query, qbtypes.RequestTypeScalar)
c.query = statementBuilder.adjustKeys(context.Background(), keysMapCopy, c.query, qbtypes.RequestTypeScalar)
// Verify select fields were adjusted
if c.expectedSelectFields != nil {

View File

@@ -216,13 +216,6 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
}
b.stmtBuilder.logger.DebugContext(ctx, "Retrieved keys for query", slog.String("query_name", queryName), slog.Int("keys_count", len(keys)))
// The CTE only selects spans matching the filter. Aggregations, group by
// and order by run later in buildFinalQuery, so RequestTypeRaw is fine here.
for _, action := range adjustTraceKeys(keys, query, qbtypes.RequestTypeRaw) {
// TODO: change to debug level once we are confident about the behavior
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
}
// Build resource filter CTE for this specific query
resourceFilterCTEName := fmt.Sprintf("__resource_filter_%s", cteName)
resourceStmt, err := b.buildResourceFilterCTE(ctx, *query)
@@ -424,28 +417,21 @@ func (b *traceOperatorCTEBuilder) buildNotCTE(leftCTE, rightCTE string) (string,
}
func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFromCTE string, requestType qbtypes.RequestType) (*qbtypes.Statement, error) {
keySelectors := b.getKeySelectors()
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
b.adjustOperatorKeys(ctx, keys, requestType)
switch requestType {
case qbtypes.RequestTypeRaw:
return b.buildListQuery(ctx, selectFromCTE, keys)
return b.buildListQuery(ctx, selectFromCTE)
case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, selectFromCTE, keys)
return b.buildTimeSeriesQuery(ctx, selectFromCTE)
case qbtypes.RequestTypeTrace:
return b.buildTraceQuery(ctx, selectFromCTE, keys)
return b.buildTraceQuery(ctx, selectFromCTE)
case qbtypes.RequestTypeScalar:
return b.buildScalarQuery(ctx, selectFromCTE, keys)
return b.buildScalarQuery(ctx, selectFromCTE)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
}
}
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
sb := sqlbuilder.NewSelectBuilder()
// Select core fields
@@ -467,6 +453,22 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
"parent_span_id": true,
}
// Get keys for selectFields
keySelectors := b.getKeySelectors()
for _, field := range b.operator.SelectFields {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: field.FieldContext,
FieldDataType: field.FieldDataType,
})
}
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
// Add selectFields using ColumnExpressionFor since we now have all base table columns
for _, field := range b.operator.SelectFields {
if selectedFields[field.Name] {
@@ -516,44 +518,6 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
}, nil
}
// adjustOperatorKeys runs the same key adjustments as adjustTraceKeys, but on
// the operator's own fields. The operator has a different struct shape than
// QueryBuilderQuery, so we copy the relevant fields into a temp query, run
// the shared helpers, and copy the results back.
func (b *traceOperatorCTEBuilder) adjustOperatorKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, requestType qbtypes.RequestType) {
mergeDeprecatedTraceKeys(keys)
tmp := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Aggregations: b.operator.Aggregations,
SelectFields: b.operator.SelectFields,
GroupBy: b.operator.GroupBy,
Order: b.operator.Order,
}
actions := querybuilder.AdjustKeysForAliasExpressions(&tmp, requestType)
actions = append(actions, querybuilder.AdjustDuplicateKeys(&tmp)...)
for idx := range tmp.SelectFields {
actions = append(actions, adjustTraceKey(&tmp.SelectFields[idx], keys)...)
}
for idx := range tmp.GroupBy {
actions = append(actions, adjustTraceKey(&tmp.GroupBy[idx].TelemetryFieldKey, keys)...)
}
for idx := range tmp.Order {
actions = append(actions, adjustTraceKey(&tmp.Order[idx].Key.TelemetryFieldKey, keys)...)
}
// Copy back the slices the helpers can rewrite.
b.operator.Aggregations = tmp.Aggregations
b.operator.SelectFields = tmp.SelectFields
b.operator.GroupBy = tmp.GroupBy
b.operator.Order = tmp.Order
for _, action := range actions {
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
}
}
func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySelector {
var keySelectors []*telemetrytypes.FieldKeySelector
@@ -581,15 +545,6 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
})
}
for _, sf := range b.operator.SelectFields {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: sf.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: sf.FieldContext,
FieldDataType: sf.FieldDataType,
})
}
for i := range keySelectors {
keySelectors[i].Signal = telemetrytypes.SignalTraces
}
@@ -597,7 +552,7 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
return keySelectors
}
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(fmt.Sprintf(
@@ -605,6 +560,12 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
int64(b.operator.StepInterval.Seconds()),
))
keySelectors := b.getKeySelectors()
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
var allGroupByArgs []any
for _, gb := range b.operator.GroupBy {
@@ -683,7 +644,8 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
combinedArgs := append(allGroupByArgs, allAggChArgs...)
// Add HAVING clause if specified
if err := b.addHavingClause(sb); err != nil {
err = b.addHavingClause(sb)
if err != nil {
return nil, err
}
@@ -710,11 +672,17 @@ func (b *traceOperatorCTEBuilder) buildTraceSummaryCTE(selectFromCTE string) {
b.addCTE("trace_summary", sql, args, []string{"all_spans", selectFromCTE})
}
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
b.buildTraceSummaryCTE(selectFromCTE)
sb := sqlbuilder.NewSelectBuilder()
keySelectors := b.getKeySelectors()
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
var allGroupByArgs []any
for _, gb := range b.operator.GroupBy {
@@ -796,7 +764,8 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
sb.GroupBy(groupByKeys...)
}
if err := b.addHavingClause(sb); err != nil {
err = b.addHavingClause(sb)
if err != nil {
return nil, err
}
@@ -852,9 +821,15 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
}, nil
}
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
sb := sqlbuilder.NewSelectBuilder()
keySelectors := b.getKeySelectors()
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
var allGroupByArgs []any
for _, gb := range b.operator.GroupBy {
@@ -936,7 +911,8 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
combinedArgs := append(allGroupByArgs, allAggChArgs...)
// Add HAVING clause if specified
if err := b.addHavingClause(sb); err != nil {
err = b.addHavingClause(sb)
if err != nil {
return nil, err
}

View File

@@ -14,24 +14,6 @@ import (
"github.com/stretchr/testify/require"
)
func newTestTraceOperatorStatementBuilder(t *testing.T) *traceOperatorStatementBuilder {
t.Helper()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
traceStmtBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, fm, cb, aggExprRewriter, nil, fl,
)
return NewTraceOperatorStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, fm, cb, traceStmtBuilder, aggExprRewriter, fl,
)
}
func TestTraceOperatorStatementBuilder(t *testing.T) {
cases := []struct {
name string
@@ -481,7 +463,32 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
}
statementBuilder := newTestTraceOperatorStatementBuilder(t)
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
traceStmtBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
nil,
fl,
)
statementBuilder := NewTraceOperatorStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
traceStmtBuilder,
aggExprRewriter,
fl,
)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -572,7 +579,32 @@ func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
},
}
statementBuilder := newTestTraceOperatorStatementBuilder(t)
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
traceStmtBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
nil,
fl,
)
statementBuilder := NewTraceOperatorStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
traceStmtBuilder,
aggExprRewriter,
fl,
)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -594,142 +626,3 @@ func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
})
}
}
func TestTraceOperatorStatementBuilderAdjustsKeys(t *testing.T) {
cases := []struct {
name string
requestType qbtypes.RequestType
operator qbtypes.QueryBuilderTraceOperator
builderFilter string
wantSQL string
wantArgs []any
}{
{
name: "deprecated duration filter in referenced builder query",
requestType: qbtypes.RequestTypeRaw,
operator: qbtypes.QueryBuilderTraceOperator{
Expression: "A",
Limit: 10,
},
builderFilter: "durationNano = '3s'",
wantSQL: "duration_nano = ?",
wantArgs: []any{int64(3000000000)},
},
{
name: "context-prefixed aggregation alias in order by",
requestType: qbtypes.RequestTypeScalar,
operator: qbtypes.QueryBuilderTraceOperator{
Expression: "A",
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
Alias: "span.count_",
},
},
Order: []qbtypes.OrderBy{
{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "count_",
FieldContext: telemetrytypes.FieldContextSpan,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
},
wantSQL: "ORDER BY __result_0 desc",
},
}
statementBuilder := newTestTraceOperatorStatementBuilder(t)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.operator.ParseExpression()
require.NoError(t, err)
filter := c.builderFilter
if filter == "" {
filter = "service.name = 'frontend'"
}
q, err := statementBuilder.Build(
context.Background(),
1747947419000,
1747983448000,
c.requestType,
c.operator,
&qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: filter},
},
},
},
},
)
require.NoError(t, err)
require.Contains(t, q.Query, c.wantSQL)
for _, arg := range c.wantArgs {
require.Contains(t, q.Args, arg)
}
})
}
}
// TestTraceOperatorStatementBuilderDeduplicatesKeys checks that a trace
// operator with the same field name listed twice in GroupBy (once with a
// context, once without) ends up with a single column in the outer SELECT
// and a single entry in GROUP BY.
func TestTraceOperatorStatementBuilderDeduplicatesKeys(t *testing.T) {
statementBuilder := newTestTraceOperatorStatementBuilder(t)
operator := qbtypes.QueryBuilderTraceOperator{
Expression: "A",
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "http.method",
FieldContext: telemetrytypes.FieldContextAttribute,
}},
// Same name, no context — should be merged with the entry above.
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "http.method",
}},
},
}
require.NoError(t, operator.ParseExpression())
q, err := statementBuilder.Build(
context.Background(),
1747947419000,
1747983448000,
qbtypes.RequestTypeScalar,
operator,
&qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'frontend'"},
},
},
},
},
)
require.NoError(t, err)
require.Contains(t, q.Query,
"SELECT toString(multiIf(mapContains(attributes_string, 'http.method') = ?, attributes_string['http.method'], NULL)) AS `http.method`, count() AS __result_0 FROM A GROUP BY `http.method` ORDER BY __result_0 DESC")
}

View File

@@ -30,12 +30,13 @@ const (
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
// their channel templates.
const (
AnnotationTitleTemplate = "_title_template"
AnnotationBodyTemplate = "_body_template"
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationValue = "value"
AnnotationThresholdValue = "threshold.value"
AnnotationCompareOp = "compare_op"
AnnotationMatchType = "match_type"
AnnotationTitleTemplate = "_title_template"
AnnotationBodyTemplate = "_body_template"
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedLogsSamples = "related_logs_samples"
AnnotationRelatedTraces = "related_traces"
AnnotationValue = "value"
AnnotationThresholdValue = "threshold.value"
AnnotationCompareOp = "compare_op"
AnnotationMatchType = "match_type"
)

View File

@@ -54,12 +54,6 @@ type Event struct {
IsError bool `json:"isError,omitempty"`
}
type OtelSpanRef struct {
TraceId string `json:"traceId,omitempty"`
SpanId string `json:"spanId,omitempty"`
RefType string `json:"refType,omitempty"`
}
// WaterfallSpan represents the span in waterfall response,
// this uses snake_case keys for response as a special case since these
// keys can be directly used to query spans and client need to know the actual fields.
@@ -80,7 +74,6 @@ type WaterfallSpan struct {
TimeUnix uint64 `json:"time_unix"`
TraceID string `json:"trace_id"`
TraceState string `json:"trace_state"`
References []OtelSpanRef `json:"references" required:"true" nullable:"false"`
// Calculated fields https://signoz.io/docs/traces-management/guides/derived-fields-spans
DBName string `json:"db_name,omitempty"`
@@ -135,7 +128,6 @@ type StorableSpan struct {
ExternalHTTPMethod string `ch:"external_http_method"`
ExternalHTTPURL string `ch:"external_http_url"`
ResponseStatusCode string `ch:"response_status_code"`
References string `ch:"references"`
}
// MinimalSpan with only the fields needed to build the parent-child tree.
@@ -293,14 +285,6 @@ func (item *StorableSpan) UnmarshalledEvents() []Event {
return events
}
func (item *StorableSpan) UnmarshalledRefs() []OtelSpanRef {
refs := []OtelSpanRef{}
if err := json.Unmarshal([]byte(item.References), &refs); err != nil {
return nil // skip malformed values
}
return refs
}
func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
resources := make(map[string]string)
maps.Copy(resources, item.ResourcesString)
@@ -334,7 +318,6 @@ func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
Children: make([]*WaterfallSpan, 0),
TimeUnix: uint64(item.StartTime.UnixNano()),
ServiceName: item.ServiceName,
References: item.UnmarshalledRefs(),
}
}

View File

@@ -54,16 +54,16 @@ func newConfig() factory.Config {
Directory: "/etc/signoz/web",
Settings: SettingsConfig{
Posthog: PosthogConfig{
Enabled: false,
Enabled: true,
},
Appcues: AppcuesConfig{
Enabled: false,
Enabled: true,
},
Sentry: SentryConfig{
Enabled: false,
Enabled: true,
},
Pylon: PylonConfig{
Enabled: false,
Enabled: true,
},
},
}

View File

@@ -459,57 +459,6 @@ def find_named_result(
)
def assert_scalar_value(
response: requests.Response,
name: str,
expected: Any,
*,
row: int = 0,
col: int = 0,
) -> None:
"""Assert that the named scalar result has `expected` at data[row][col]."""
result = find_named_result(response.json()["data"]["data"]["results"], name)
assert result is not None, f"no result for query {name}"
assert result["data"][row][col] == expected, f"expected {expected} at [{row}][{col}], got {result['data'][row][col]}"
def assert_grouped_scalar(
response: requests.Response,
name: str,
*,
expected_groups: int,
expected_columns: int,
last_col_value: Any | None = None,
) -> None:
"""Assert grouped scalar result has the expected column count and group count.
If `last_col_value` is set and there is exactly one group, also assert the
last column of that single row equals it (a common aggregation-value check)."""
result = find_named_result(response.json()["data"]["data"]["results"], name)
assert result is not None, f"no result for query {name}"
columns = result["columns"]
rows = result["data"]
assert len(columns) == expected_columns, f"expected {expected_columns} columns, got {len(columns)}: {columns}"
assert len(rows) == expected_groups, f"expected {expected_groups} groups, got {len(rows)}: {rows}"
if last_col_value is not None and expected_groups == 1:
assert rows[0][-1] == last_col_value, f"expected last col {last_col_value}, got row {rows[0]}"
def assert_raw_row_subset(
response: requests.Response,
name: str,
expected: dict[str, Any],
*,
row: int = 0,
) -> None:
"""Assert that the named raw result's rows[row]['data'] is a superset of `expected`."""
result = find_named_result(response.json()["data"]["data"]["results"], name)
assert result is not None, f"no result for query {name}"
rows = result["rows"]
assert rows is not None, f"no rows for query {name}"
data = rows[row]["data"]
assert expected.items() <= data.items(), f"expected subset {expected}, got data {data}"
def build_scalar_query(
name: str,
signal: str,

View File

@@ -25,22 +25,13 @@ returnSpansFrom="A"
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from typing import Any
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
assert_grouped_scalar,
assert_raw_row_subset,
assert_scalar_value,
format_timestamp,
generate_traces_with_corrupt_metadata,
get_rows,
make_query_request,
)
from fixtures.querier import get_rows
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
@@ -443,173 +434,3 @@ def test_trace_operator(
)
assert response.status_code == HTTPStatus.OK, f"HTTP {response.status_code}: {response.text}"
assert case["validate"](response), f"validation failed: {response.json()}"
def _expected_trace_subset(trace: Traces) -> dict[str, Any]:
return {
"duration_nano": trace.duration_nano,
"name": trace.name,
"parent_span_id": trace.parent_span_id,
"span_id": trace.span_id,
"timestamp": format_timestamp(trace.timestamp),
"trace_id": trace.trace_id,
}
@pytest.mark.parametrize(
"payload_factory,request_type,assert_result",
[
# Case 1: CTE filter uses the deprecated intrinsic field `durationNano`.
pytest.param(
lambda traces: [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {"expression": 'durationNano = "3s"'},
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"filter": {"expression": 'durationNano = "5s"'},
},
},
{
"type": "builder_trace_operator",
"spec": {
"name": "C",
"expression": "A => B",
"limit": 1,
},
},
],
"raw",
lambda response, traces: assert_raw_row_subset(response, "C", _expected_trace_subset(traces[0])),
id="deprecated-intrinsic-filter",
),
# Case 2: CTE filter uses the deprecated calculated field `responseStatusCode`.
pytest.param(
lambda traces: [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {"expression": 'responseStatusCode = "200"'},
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"filter": {"expression": 'durationNano = "5s"'},
},
},
{
"type": "builder_trace_operator",
"spec": {
"name": "C",
"expression": "A => B",
"limit": 1,
},
},
],
"raw",
lambda response, traces: assert_raw_row_subset(response, "C", _expected_trace_subset(traces[0])),
id="deprecated-calculated-filter",
),
# Case 3: order by uses `count_` with fieldContext `span`, which has
# to be rewritten to the aggregation alias `span.count_`.
pytest.param(
lambda traces: [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_trace_operator",
"spec": {
"name": "C",
"expression": "A",
"aggregations": [{"expression": "count()", "alias": "span.count_"}],
"order": [{"key": {"name": "count_", "fieldContext": "span"}, "direction": "desc"}],
},
},
],
"scalar",
lambda response, traces: assert_scalar_value(response, "C", len(traces)),
id="context-prefixed-aggregation-alias-order",
),
# Case 4: group by lists `cloud.provider` twice (once with a resource
# context, once without).
pytest.param(
lambda traces: [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": True,
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_trace_operator",
"spec": {
"name": "C",
"expression": "A",
"aggregations": [{"expression": "count()"}],
"groupBy": [
{"name": "cloud.provider", "fieldContext": "resource"},
{"name": "cloud.provider"},
],
},
},
],
"scalar",
lambda response, traces: assert_grouped_scalar(response, "C", expected_groups=1, expected_columns=2, last_col_value=len(traces)),
id="duplicate-group-by-deduplicated",
),
],
)
def test_trace_operator_with_adjusted_keys(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
payload_factory: Callable[[list[Traces]], list[dict[str, Any]]],
request_type: str,
assert_result: Callable[[requests.Response, list[Traces]], None],
) -> None:
"""
Trace operators build a CTE per referenced builder query and an outer
query on top. Both layers need the same key adjustment as regular trace
queries, otherwise deprecated keys and context-prefixed aliases don't
resolve.
"""
traces = generate_traces_with_corrupt_metadata()
insert_traces(traces)
payload = payload_factory(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type=request_type,
queries=payload,
)
assert response.status_code == HTTPStatus.OK, response.text
assert_result(response, traces)