Compare commits

...

22 Commits

Author SHA1 Message Date
aks07
9f0d5f8376 test(trace-details): remove tests which could go as unit or integration 2026-06-29 18:50:32 +05:30
aks07
1261dbf670 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-e2e 2026-06-29 18:33:14 +05:30
aks07
78bf137a43 test(trace-details): add pinned side nav logic 2026-06-29 18:18:44 +05:30
aks07
4e6ca583b6 test(trace-details): use playwright context instead of browser 2026-06-29 18:17:57 +05:30
aks07
32ff3dccf8 test(trace-details): add common helpers 2026-06-29 18:16:20 +05:30
aks07
3a992eabec Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-details-e2e 2026-06-29 16:30:46 +05:30
aks07
c348773e09 test(trace-details): minor refactor 2026-06-26 10:28:54 +05:30
aks07
fb47df674c test(trace-details): add preview-fields hover card e2e 2026-06-26 10:28:54 +05:30
aks07
8269e76a3a test(trace-details): add span details drawer e2e 2026-06-26 10:28:54 +05:30
aks07
8d7c10bd40 test(trace-details): add analytics panel e2e 2026-06-26 10:28:54 +05:30
aks07
50fad6bb19 test(trace-details): add highlight-errors filter e2e 2026-06-26 10:28:54 +05:30
aks07
a1c9b86ff5 test(trace-details): add waterfall e2e + row instrumentation 2026-06-26 10:28:54 +05:30
aks07
5a504fd6db test(trace-details): add flamegraph e2e + canvas test hook 2026-06-26 10:28:54 +05:30
aks07
dcde938423 test(trace-details): add e2e helper and large-trace fixture 2026-06-26 10:28:54 +05:30
aks07
a0b2256d46 feat(trace-details): fix failing test 2026-06-26 09:56:34 +05:30
aks07
6bbf5473dd feat(trace-details): remove unused trace details v2 code 2026-06-26 09:56:16 +05:30
aks07
38d056b9c0 feat(trace-details): remove Trace Details V2 page and its module import 2026-06-26 09:52:59 +05:30
aks07
8ad7d2dd20 fix(trace-details): fix serviceName path in trace funnel 2026-06-26 09:51:09 +05:30
aks07
7dc5b1fd0b feat(trace-details): remove usage of getTraceV2 from V3 code 2026-06-26 09:51:09 +05:30
aks07
0b6cc1d21f feat(trace-details): move events out from v2 to v3 before cleanup 2026-06-26 09:51:09 +05:30
aks07
9889212225 feat(trace-details): move span logs out from v2 to v3 before cleanup 2026-06-26 09:51:09 +05:30
aks07
ae463fa042 feat(trace-details): move no-data component from v2 code to v3 before v2 cleanup 2026-06-26 09:51:09 +05:30
17 changed files with 3465 additions and 4 deletions

View File

@@ -148,7 +148,7 @@ function AnalyticsPanel({
className="floating-panel__drag-handle"
/>
<div className={styles.body}>
<div className={styles.body} data-testid="trace-analytics-panel">
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
<TabsList variant="secondary">
<TabsTrigger value="exec-time" variant="secondary">

View File

@@ -60,7 +60,7 @@ function DockModeSwitcher({
{DOCK_OPTIONS.map((option) => (
<TooltipRoot key={option.value}>
<TooltipTrigger asChild>
<span>
<span data-testid={`dock-mode-${option.value}`}>
<ToggleGroupItem value={option.value}>{option.icon}</ToggleGroupItem>
</span>
</TooltipTrigger>

View File

@@ -64,7 +64,11 @@ export function SpanTooltipContent({
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
{previewRows.map((row) => (
<div key={row.key} className={styles.row}>
<div
key={row.key}
className={styles.row}
data-testid={`span-hover-card-preview-${row.key}`}
>
<span className={styles.previewKey}>{row.key}:</span>{' '}
<span className={styles.previewValue}>{row.value}</span>
</div>

View File

@@ -12,6 +12,7 @@ import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
import { useFlamegraphTestHook } from './hooks/useFlamegraphTestHook';
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
import { useScrollToSpan } from './hooks/useScrollToSpan';
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
@@ -159,6 +160,14 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
useCanvasSetup(canvasRef, containerRef, drawFlamegraph, overlayCanvasRef);
// E2E-only: expose the live span→rect map so specs can target canvas bars.
// No-op unless window.__SIGNOZ_E2E__ is set (Playwright addInitScript).
useFlamegraphTestHook({
canvasRef,
containerRef,
spanRectsRef,
});
const {
cursorXPercent,
cursorX,

View File

@@ -0,0 +1,101 @@
import { MutableRefObject, useEffect } from 'react';
import { SpanRect } from '../types';
/**
* E2E test hook for the canvas flamegraph. The flamegraph is `<canvas>`, so
* individual bars have no DOM nodes to target — but `spanRectsRef` already
* holds the live span→rectangle map (CSS pixels) used for hit-testing. This
* exposes a thin, read-only view of it on `window.__sigTraceFlame__` so a
* Playwright spec can resolve a span's on-screen point and drive real
* hover/click events at it (see tests/e2e/helpers/trace-details.ts).
*
* Gated on `window.__SIGNOZ_E2E__` (set by Playwright via addInitScript), so
* nothing is attached in normal runtime — the e2e build is a production build,
* so this must be a RUNTIME flag, not a NODE_ENV/mode check.
*/
interface Point {
x: number;
y: number;
}
interface FlamegraphTestApi {
getSpanPoint: (spanId: string) => Point | null;
isSpanInView: (spanId: string) => boolean;
// Resting group color of a span's bar — changes when colour-by changes.
getSpanColor: (spanId: string) => string | null;
}
declare global {
interface Window {
__SIGNOZ_E2E__?: boolean;
__sigTraceFlame__?: FlamegraphTestApi;
}
}
// Inverse of `getCanvasPointer` in useFlamegraphHover: a CSS-space span rect
// maps back to a viewport point at the bar's center.
function rectToViewportCenter(canvas: HTMLCanvasElement, r: SpanRect): Point {
const box = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const cssHeight = canvas.height / dpr;
const cssX = r.x + r.width / 2;
const cssY = r.y + r.height / 2;
return {
x: box.left + cssX * (box.width / cssWidth),
y: box.top + cssY * (box.height / cssHeight),
};
}
interface UseFlamegraphTestHookParams {
canvasRef: MutableRefObject<HTMLCanvasElement | null>;
containerRef: MutableRefObject<HTMLDivElement | null>;
spanRectsRef: MutableRefObject<SpanRect[]>;
}
export function useFlamegraphTestHook({
canvasRef,
containerRef,
spanRectsRef,
}: UseFlamegraphTestHookParams): void {
useEffect(() => {
if (!window.__SIGNOZ_E2E__) {
return undefined;
}
// Reads `.current` at call time, so it always reflects the latest draw.
const findRect = (spanId: string): SpanRect | undefined =>
spanRectsRef.current.find((r) => r.span.spanId === spanId);
window.__sigTraceFlame__ = {
getSpanPoint: (spanId): Point | null => {
const canvas = canvasRef.current;
const rect = findRect(spanId);
return canvas && rect ? rectToViewportCenter(canvas, rect) : null;
},
isSpanInView: (spanId): boolean => {
const canvas = canvasRef.current;
const container = containerRef.current;
const rect = findRect(spanId);
if (!canvas || !container || !rect) {
return false;
}
const pt = rectToViewportCenter(canvas, rect);
const box = container.getBoundingClientRect();
return (
pt.x >= box.left &&
pt.x <= box.right &&
pt.y >= box.top &&
pt.y <= box.bottom
);
},
getSpanColor: (spanId): string | null => findRect(spanId)?.color ?? null,
};
return (): void => {
delete window.__sigTraceFlame__;
};
}, [canvasRef, containerRef, spanRectsRef]);
}

View File

@@ -28,6 +28,9 @@ export interface SpanRect {
width: number;
height: number;
level: number;
// Resting fill color for the current colour-by grouping. Optional: only the
// draw path sets it; consumers (e.g. the e2e colour-by hook) read it.
color?: string;
}
export interface EventRect {

View File

@@ -279,6 +279,9 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
width,
height: metrics.SPAN_BAR_HEIGHT,
level: levelIndex,
// Resting group color (selected/hovered bars override the fill, but this
// still reflects the colour-by grouping — used by the e2e colour-by hook).
color: isDarkMode ? color : colorDark,
});
span.event?.forEach((event) => {

View File

@@ -259,7 +259,10 @@ function Filters({
);
const highlightErrorsToggle = (
<div className={styles.highlightErrorsToggle}>
<div
className={styles.highlightErrorsToggle}
data-testid="highlight-errors-toggle"
>
<Typography.Text>Highlight errors</Typography.Text>
<Switch
color="cherry"

View File

@@ -246,6 +246,19 @@ const SpanOverview = memo(function SpanOverview({
onAddSpanToFunnel(span);
};
// e2e hook: expose the filter highlight/dim state as a stable attribute, since
// the styles.* classes are hashed at build time and can't be asserted.
let spanState = 'default';
if (isHighlighted) {
spanState = 'highlighted';
} else if (isDimmed) {
spanState = 'dimmed';
} else if (isSelectedNonMatching) {
spanState = 'selected-non-matching';
} else if (isSelected) {
spanState = 'selected';
}
return (
<div
className={cx(styles.spanOverview, {
@@ -254,6 +267,7 @@ const SpanOverview = memo(function SpanOverview({
[styles.isSelectedNonMatching]: isSelectedNonMatching,
[styles.isDimmed]: isDimmed,
})}
data-span-state={spanState}
onClick={(): void => handleSpanClick(span)}
onMouseEnter={(): void => onHoverEnter(span.span_id)}
onMouseLeave={(): void => onHoverLeave()}
@@ -301,6 +315,7 @@ const SpanOverview = memo(function SpanOverview({
{span.has_children && (
<span
className={styles.treeArrow}
data-testid={`cell-collapse-${span.span_id}`}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();

View File

@@ -0,0 +1,37 @@
import type { Page } from '@playwright/test';
// Shared helpers used across feature-specific helper modules (dashboards,
// trace-details, …). Keep this to genuinely cross-feature utilities.
// ─── Seeder ────────────────────────────────────────────────────────────────
// Base URL of the HTTP seeder container the pytest harness brings up (exposes
// POST/DELETE on /telemetry/{traces,logs,metrics}). Written to
// `tests/e2e/.env.local` as `SIGNOZ_E2E_SEEDER_URL` and read here from the env.
export function seederUrl(): string {
const url = process.env.SIGNOZ_E2E_SEEDER_URL;
if (!url) {
throw new Error(
'SIGNOZ_E2E_SEEDER_URL not set — pytest test_setup must be running.',
);
}
return url;
}
// ─── Auth ────────────────────────────────────────────────────────────────
// Read the app JWT from the context's stored auth state. No navigation needed:
// the auth fixture loads the admin storageState (localStorage AUTH_TOKEN) into
// the context at creation, so storageState() returns it regardless of the page's
// current URL. Server-side APIs need this as a Bearer token (auth is
// JWT-in-localStorage, not cookies, so request.* doesn't carry it automatically).
export async function authToken(page: Page): Promise<string> {
const state = await page.context().storageState();
for (const origin of state.origins) {
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
if (entry) {
return entry.value;
}
}
throw new Error('AUTH_TOKEN not found in storage state — is the page authed?');
}

View File

@@ -0,0 +1,405 @@
import { randomBytes } from 'crypto';
import type { APIRequestContext, Page } from '@playwright/test';
import largeTraceRecords from '../testdata/traces/large-trace.json';
import { authToken, seederUrl } from './common';
// ── Seeder: insert traces via POST /telemetry/traces ─────────────────────────
// Shape accepted by the seeder's POST /telemetry/traces endpoint
// (mirrors `Traces.from_dict` in tests/fixtures/traces.py). One object per span;
// spans sharing a `trace_id` form one trace, linked into a tree via
// `parent_span_id`. NOTE: the endpoint does NOT ingest span events/links.
export interface SeederSpan {
timestamp: string; // ISO-8601, e.g. new Date().toISOString()
trace_id: string; // 32 hex chars
span_id: string; // 16 hex chars
parent_span_id?: string; // empty/omitted = root span
name?: string;
kind?: number; // 1=internal 2=server 3=client 4=producer 5=consumer
status_code?: number; // 0=unset 1=ok 2=error
status_message?: string;
duration?: string; // ISO-8601 duration, e.g. "PT0.12S" (default PT1S)
resources?: Record<string, string>; // include 'service.name'
attributes?: Record<string, unknown>;
}
// 16-byte trace id / 8-byte span id, matching tests/fixtures/traces.py.
export const randomTraceId = (): string => randomBytes(16).toString('hex');
export const randomSpanId = (): string => randomBytes(8).toString('hex');
// Insert spans into the backend via the seeder. No auth needed (direct seeder
// call), so any APIRequestContext works — `page.request` or a standalone
// `playwright.request.newContext()` (cheaper than a full browser page for a
// pure API call).
//
// The seeder shares a single ClickHouse client, so concurrent POSTs from
// parallel workers collide with a 500 "concurrent queries within the same
// session". That's transient, so retry with backoff; any other error is real.
export async function seedTracesViaSeeder(
request: APIRequestContext,
spans: SeederSpan[],
): Promise<void> {
const url = `${seederUrl()}/telemetry/traces`;
const maxAttempts = 6;
let lastStatus = 0;
let lastText = '';
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
// eslint-disable-next-line no-await-in-loop
const res = await request.post(url, {
data: spans,
headers: { 'Content-Type': 'application/json' },
});
if (res.ok()) {
return;
}
lastStatus = res.status();
// eslint-disable-next-line no-await-in-loop
lastText = await res.text();
if (!(lastStatus === 500 && lastText.includes('concurrent'))) {
break;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 150 * (attempt + 1) + Math.floor(Math.random() * 100));
});
}
throw new Error(`seeder POST /telemetry/traces ${lastStatus}: ${lastText}`);
}
// ── Navigation ───────────────────────────────────────────────────────────────
// Pages that already had the e2e test-hook init script registered, so
// gotoTraceUntilLoaded adds it at most once per Page (addInitScript re-runs on
// every navigation, and the script would otherwise stack up across calls).
const e2eHookRegistered = new WeakSet<Page>();
// Open a seeded trace and wait until the waterfall has rendered. The trace page
// fetches once on load, so if the seed isn't query-able yet (ClickHouse lag, worse
// under parallel load) it lands on the NoData state and never refetches — this
// reloads until the given row testid appears. Makes seeded-trace specs
// deterministic in the full parallel run, not just when run alone.
export async function gotoTraceUntilLoaded(
page: Page,
url: string,
readyTestId: string,
{ attempts = 5, perAttemptTimeoutMs = 8000 } = {},
): Promise<void> {
// Enable e2e-only test hooks (e.g. the flamegraph span→rect map in
// useFlamegraphTestHook) before the first navigation. Registered here because
// every trace-detail spec loads the page through this helper, so the flag is
// set without a dedicated fixture. Guarded to once per Page — addInitScript
// re-runs on every navigation, so re-registering would stack duplicates.
if (!e2eHookRegistered.has(page)) {
await page.addInitScript(() => {
(window as unknown as { __SIGNOZ_E2E__?: boolean }).__SIGNOZ_E2E__ = true;
});
// Dock the left nav so it doesn't fly out on hover and overlay the trace
// content's left strip (which otherwise makes left-edge hover/click targets
// land on the sidebar). Once per Page, before the first navigation.
await pinSidenav(page);
e2eHookRegistered.add(page);
}
for (let i = 0; i < attempts; i += 1) {
// eslint-disable-next-line no-await-in-loop
await page.goto(url);
try {
// eslint-disable-next-line no-await-in-loop
await page
.getByTestId(readyTestId)
.waitFor({ state: 'visible', timeout: perAttemptTimeoutMs });
return;
} catch {
// not loaded yet (NoData / seed lag) — reload and retry
}
}
// final navigation so the test's own assertion surfaces a clear failure
await page.goto(url);
}
// ── Trace options menu ─────────────────────────────────────────────────────
// Change the colour-by field via the trace options menu (Trace options → Colour
// by → field). colour-by is a per-user preference that persists, so tests should
// set a known field explicitly rather than assume the default. `fieldName` is a
// COLOR_BY_OPTIONS label (service.name | service.namespace | host.name |
// k8s.node.name | k8s.container.name); exact match avoids service.name matching
// service.namespace.
export async function changeColourByViaMenu(
page: Page,
fieldName: string,
): Promise<void> {
await page.getByRole('button', { name: 'Trace options' }).click();
await page.getByRole('menuitem', { name: /colour by/i }).click();
await page
.getByRole('menuitemradio', { name: fieldName, exact: true })
.click();
}
// ── Large trace fixture (tests/e2e/testdata/traces/large-trace.json) ─────────
// One deep, realistic trace: 100 spans across 18 services, nested ~34 levels,
// 8 error spans, a wide duration spread, and db/http/llm/messaging attributes —
// enough to drive the flamegraph, waterfall, filters and drawer off one seed.
// Converted once from a real getWaterfallV4 capture. `loadLargeTrace()` stamps
// fresh ids per run (parallel isolation), rebases the timeline to ~now, and
// derives landmark span ids so specs target rows without hardcoding ids.
// Shape of each record in large-trace.json.
interface LargeTraceRecord {
span_id: string;
parent_span_id: string; // empty = root
name: string;
kind: number;
status_code: number;
duration: string; // ISO-8601, e.g. "PT0.080000S"
offset_ms: number; // start offset from the root span
resources: Record<string, string>;
attributes: Record<string, unknown>;
}
const LARGE_TRACE_RECORDS = largeTraceRecords as LargeTraceRecord[];
export interface LargeTrace {
traceId: string;
spans: SeederSpan[];
// landmark span ids — already stamped — for targeting rows / the drawer
landmarks: {
root: string;
errors: string[];
db: string;
http: string;
llm: string;
messaging: string;
deepLeaf: string;
};
}
// Depth of a record via its parent chain (the JSON doesn't store level).
function recordDepth(
rec: LargeTraceRecord,
byId: Map<string, LargeTraceRecord>,
): number {
let depth = 0;
let cur: LargeTraceRecord | undefined = rec;
while (cur && cur.parent_span_id) {
cur = byId.get(cur.parent_span_id);
depth += 1;
}
return depth;
}
// Build a seedable copy of the large trace with fresh, isolated ids.
export function loadLargeTrace(): LargeTrace {
const traceId = randomTraceId();
// Stamp a fresh span id for every original id, preserving the tree links.
const idMap = new Map<string, string>();
LARGE_TRACE_RECORDS.forEach((r) => idMap.set(r.span_id, randomSpanId()));
// Sit the whole trace ~1 min in the past so all timestamps stay <= now.
const baseStartMs = Date.now() - 60_000;
const spans: SeederSpan[] = LARGE_TRACE_RECORDS.map((r) => {
const span: SeederSpan = {
timestamp: new Date(baseStartMs + r.offset_ms).toISOString(),
trace_id: traceId,
span_id: idMap.get(r.span_id) as string,
name: r.name,
kind: r.kind,
status_code: r.status_code,
duration: r.duration,
resources: r.resources,
attributes: r.attributes,
};
if (r.parent_span_id) {
span.parent_span_id = idMap.get(r.parent_span_id);
}
return span;
});
const byId = new Map(LARGE_TRACE_RECORDS.map((r) => [r.span_id, r]));
const stamp = (r: LargeTraceRecord | undefined): string =>
r ? (idMap.get(r.span_id) as string) : '';
const firstWithAttr = (key: string): LargeTraceRecord | undefined =>
LARGE_TRACE_RECORDS.find((r) => key in r.attributes);
const deepest = LARGE_TRACE_RECORDS.reduce((a, b) =>
recordDepth(b, byId) > recordDepth(a, byId) ? b : a,
);
const landmarks = {
root: stamp(LARGE_TRACE_RECORDS.find((r) => !r.parent_span_id)),
errors: LARGE_TRACE_RECORDS.filter((r) => r.status_code === 2).map((r) =>
stamp(r),
),
db: stamp(firstWithAttr('db.system')),
http: stamp(firstWithAttr('http.method')),
llm: stamp(firstWithAttr('gen_ai.request.model')),
messaging: stamp(firstWithAttr('messaging.system')),
deepLeaf: stamp(deepest),
};
return { traceId, spans, landmarks };
}
// ── Flamegraph canvas test hook ──────────────────────────────────────────────
// The flamegraph is canvas-rendered, so individual bars have no DOM nodes. The
// frontend exposes a read-only span→rect view on window.__sigTraceFlame__
// (useFlamegraphTestHook), present only when __SIGNOZ_E2E__ is set — which
// gotoTraceUntilLoaded injects via addInitScript.
// Mirror of the API exposed by useFlamegraphTestHook.
interface FlamegraphTestApi {
getSpanPoint: (spanId: string) => { x: number; y: number } | null;
isSpanInView: (spanId: string) => boolean;
getSpanColor: (spanId: string) => string | null;
}
interface FlameWindow {
__sigTraceFlame__?: FlamegraphTestApi;
}
// Resolve a span's on-canvas viewport point, waiting through the first paint
// (the hook + spanRects populate only after the flamegraph's draw rAF).
async function spanPoint(
page: Page,
spanId: string,
): Promise<{ x: number; y: number }> {
const handle = await page.waitForFunction(
(id) =>
(window as unknown as FlameWindow).__sigTraceFlame__?.getSpanPoint(id) ??
null,
spanId,
{ timeout: 10_000 },
);
const point = await handle.jsonValue();
if (!point) {
throw new Error(`flamegraph span "${spanId}" is not drawn on the canvas`);
}
return point;
}
// Hover the flamegraph bar for `spanId` (opens its SpanHoverCard).
export async function hoverFlamegraphSpan(
page: Page,
spanId: string,
): Promise<void> {
const { x, y } = await spanPoint(page, spanId);
await page.mouse.move(x, y);
}
// Click the flamegraph bar for `spanId` (selects the span / opens the drawer).
export async function clickFlamegraphSpan(
page: Page,
spanId: string,
): Promise<void> {
const { x, y } = await spanPoint(page, spanId);
await page.mouse.move(x, y);
await page.mouse.click(x, y);
}
// Whether `spanId`'s bar is currently drawn AND inside the viewport container.
export async function isFlamegraphSpanInView(
page: Page,
spanId: string,
): Promise<boolean> {
return page.evaluate(
(id) =>
(window as unknown as FlameWindow).__sigTraceFlame__?.isSpanInView(id) ??
false,
spanId,
);
}
// Resting group color of a span's bar — used to assert colour-by recolor.
export async function getFlamegraphSpanColor(
page: Page,
spanId: string,
): Promise<string | null> {
return page.evaluate(
(id) =>
(window as unknown as FlameWindow).__sigTraceFlame__?.getSpanColor(id) ??
null,
spanId,
);
}
// ── User preferences (server-side, per-user) ─────────────────────────────────
// Trace-detail user-preference keys (mirror frontend constants/userPreferences.ts).
export const TRACE_PREFERENCE = {
COLOR_BY: 'span_details_color_by_attribute',
PREVIEW_FIELDS: 'span_details_preview_attributes',
PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
} as const;
// Whether the left nav is docked/pinned (mirror USER_PREFERENCES.SIDENAV_PINNED).
const SIDENAV_PINNED = 'sidenav_pinned';
// A telemetry field key as persisted in the preview-fields preference. Only
// `name` is required by the store (derivePreviewFields), but fieldContext /
// fieldDataType match how the UI persists them.
export interface PreviewFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
// PUT a single user preference (server-side, per-user). Call BEFORE navigating
// to the trace page so its on-mount preference fetch returns the seeded value.
//
// NOTE: user preferences are GLOBAL PER USER, not per-test — they persist on the
// server for the admin user. Reset them (resetTracePreferences) in afterAll, and
// be aware other specs run by the same user in parallel share this state.
export async function setUserPreference(
page: Page,
name: string,
value: unknown,
): Promise<void> {
const token = await authToken(page);
const res = await page.request.put(`/api/v1/user/preferences/${name}`, {
data: { value },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`PUT /api/v1/user/preferences/${name} ${res.status()}: ${await res.text()}`,
);
}
}
// Persist the flamegraph color-by field. `fieldName` must be one of
// COLOR_BY_OPTIONS (service.name | service.namespace | host.name |
// k8s.node.name | k8s.container.name); '' falls back to the default.
export async function setColorByPreference(
page: Page,
fieldName: string,
): Promise<void> {
await setUserPreference(page, TRACE_PREFERENCE.COLOR_BY, fieldName);
}
// Persist the span-details preview fields (shown as rows in the hover card).
export async function setPreviewFieldsPreference(
page: Page,
fields: PreviewFieldKey[],
): Promise<void> {
await setUserPreference(page, TRACE_PREFERENCE.PREVIEW_FIELDS, fields);
}
// Reset trace-detail prefs to defaults. Run in afterAll so a prefs spec doesn't
// leak color-by / preview-field state into other specs for the same user.
export async function resetTracePreferences(page: Page): Promise<void> {
await setColorByPreference(page, '');
await setPreviewFieldsPreference(page, []);
}
// Pin (dock) the left nav. When unpinned it's a collapsed rail that flies out on
// hover as an absolute OVERLAY, covering the trace content's left strip — so
// hover/click on left-edge targets (the waterfall collapse arrow, flamegraph
// bars) lands on the sidebar instead. Pinned, it's a flex child that reserves
// layout space, so nothing is occluded. Set before navigating: the server pref
// wins over localStorage once preferences load.
export async function pinSidenav(page: Page): Promise<void> {
await setUserPreference(page, SIDENAV_PINNED, true);
}

2482
tests/e2e/testdata/traces/large-trace.json vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
import { test, expect } from '../../fixtures/auth';
import {
gotoTraceUntilLoaded,
loadLargeTrace,
seedTracesViaSeeder,
} from '../../helpers/trace-details';
// One shared trace for the whole file, seeded once. Unique ids per run keep this
// isolated from other parallel specs; the global teardown clears the traces signal.
const trace = loadLargeTrace();
test.describe('Trace details — span details drawer', () => {
test.beforeAll(async ({ playwright }) => {
// Seed once via a disposable request context — no auth needed (direct
// seeder call), and cheaper than spinning up a full browser page.
const request = await playwright.request.newContext();
await seedTracesViaSeeder(request, trace.spans);
await request.dispose();
});
test.beforeEach(async ({ authedPage: page }) => {
// open the trace, reloading until the waterfall renders (seed→query lag)
await gotoTraceUntilLoaded(
page,
`/trace/${trace.traceId}`,
`cell-0-${trace.landmarks.root}`,
);
});
test('TC-03 dock-mode switching toggles the drawer between floating and docked', async ({
authedPage: page,
}) => {
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
// Default is docked-right → not a floating panel (no drag handle).
await expect(page.locator('.floating-panel__drag-handle')).toHaveCount(0);
// Switch to floating (dialog) → the drag handle appears.
await page.getByTestId('dock-mode-dialog').click();
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
// Switch to docked-bottom → floating handle gone again.
await page.getByTestId('dock-mode-docked').click();
await expect(page.locator('.floating-panel__drag-handle')).toHaveCount(0);
});
test('TC-04 the floating drawer can be dragged', async ({
authedPage: page,
}) => {
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
await page.getByTestId('dock-mode-dialog').click();
const handle = page.locator('.floating-panel__drag-handle');
await expect(handle).toBeVisible();
const zero = { x: 0, y: 0, width: 0, height: 0 };
const before = (await handle.boundingBox()) ?? zero;
// Drag from the left of the header (title area) to avoid the action buttons.
const startX = before.x + 30;
const startY = before.y + before.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX - 120, startY + 80, { steps: 8 });
await page.mouse.up();
await expect
.poll(async () => Math.round(((await handle.boundingBox()) ?? before).x))
.toBeLessThan(Math.round(before.x));
});
test('TC-05 a dock-mode change persists and is restored on reload', async ({
authedPage: page,
}) => {
// §0 prefs-boot, UI-first: switch to floating via the dock-mode UI (which
// persists the variant), then reload and confirm it's restored — the drawer
// boots floating, not the docked-right default.
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
await page.getByTestId('dock-mode-dialog').click();
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
await page.reload();
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
});
});

View File

@@ -0,0 +1,122 @@
import { test, expect } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
changeColourByViaMenu,
clickFlamegraphSpan,
getFlamegraphSpanColor,
gotoTraceUntilLoaded,
hoverFlamegraphSpan,
isFlamegraphSpanInView,
loadLargeTrace,
seedTracesViaSeeder,
setColorByPreference,
} from '../../helpers/trace-details';
// The flamegraph is canvas-rendered, so individual bars have no DOM nodes. These
// specs drive it through the window.__sigTraceFlame__ test hook (enabled by
// gotoTraceUntilLoaded) — see helpers/trace-details.ts — which resolves a span's
// on-canvas point from the live span→rect map and dispatches real mouse events.
//
// One shared trace for the file, seeded once. Random ids per run isolate it from
// other parallel specs; the global teardown clears the traces signal.
//
// Colour-by recolor is asserted via the hook's getSpanColor (the resting group
// color per bar), since canvas pixels aren't directly assertable.
//
// Deferred: sampled large trace — sampling needs >100k spans
// (FLAMEGRAPH_SPAN_LIMIT), which is the deferred large-trace work.
const trace = loadLargeTrace();
test.describe('Trace details — flamegraph', () => {
test.beforeAll(async ({ playwright }) => {
const request = await playwright.request.newContext();
await seedTracesViaSeeder(request, trace.spans);
await request.dispose();
});
test.afterAll(async ({ browser }) => {
// TC-05 changes colour-by — a per-user pref. Reset it so it doesn't leak to
// other specs (afterAll can't use the test-scoped authedPage fixture).
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
await setColorByPreference(page, '');
await ctx.close();
});
test.beforeEach(async ({ authedPage: page }) => {
await gotoTraceUntilLoaded(
page,
`/trace/${trace.traceId}`,
`cell-0-${trace.landmarks.root}`,
);
});
test('TC-01 hovering an error bar opens its hover card with status/start/duration', async ({
authedPage: page,
}) => {
await hoverFlamegraphSpan(page, trace.landmarks.errors[0]);
// "status: error" only renders in the hover card (not in waterfall rows),
// so it proves both that the card opened and that we hovered the right
// (error) span — the bar was targeted by id via the span→rect map.
await expect(page.getByText('status: error')).toBeVisible();
await expect(page.getByText(/start: [\d.]+ ms/)).toBeVisible();
await expect(page.getByText(/duration: [\d.]+/)).toBeVisible();
});
test('TC-02 hovering a non-error bar shows status: ok', async ({
authedPage: page,
}) => {
await hoverFlamegraphSpan(page, trace.landmarks.db);
await expect(page.getByText('status: ok')).toBeVisible();
});
test('TC-03 clicking a bar selects the span, opens the drawer, and syncs the waterfall row', async ({
authedPage: page,
}) => {
await clickFlamegraphSpan(page, trace.landmarks.db);
// selection is reflected in the shared URL state...
await expect(page).toHaveURL(new RegExp(`spanId=${trace.landmarks.db}`));
// ...the drawer opens (Overview tab is drawer-only)...
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
// ...and the same span's waterfall row is present (views share selection).
await expect(page.getByTestId(`cell-0-${trace.landmarks.db}`)).toBeVisible();
});
test('TC-04 deep-linking a deeply-nested span scrolls it into view on the flamegraph', async ({
authedPage: page,
}) => {
// Open pre-pointed at a deep (level ~34) span; useScrollToSpan should
// center it, so its bar becomes drawn and inside the viewport container.
await gotoTraceUntilLoaded(
page,
`/trace/${trace.traceId}?spanId=${trace.landmarks.deepLeaf}`,
`cell-0-${trace.landmarks.deepLeaf}`,
);
await expect
.poll(() => isFlamegraphSpanInView(page, trace.landmarks.deepLeaf))
.toBe(true);
});
test('TC-05 changing colour-by recolors the flamegraph bars', async ({
authedPage: page,
}) => {
// colour-by persists per-user, so set an explicit baseline rather than
// assuming the default. Root's color under service.name:
await changeColourByViaMenu(page, 'service.name');
const colorByService = await getFlamegraphSpanColor(
page,
trace.landmarks.root,
);
expect(colorByService).not.toBeNull();
// Switch to host.name → root groups by a different value → new color.
await changeColourByViaMenu(page, 'host.name');
await expect
.poll(() => getFlamegraphSpanColor(page, trace.landmarks.root))
.not.toBe(colorByService);
});
});

View File

@@ -0,0 +1,57 @@
import { test, expect } from '../../fixtures/auth';
import {
gotoTraceUntilLoaded,
loadLargeTrace,
seedTracesViaSeeder,
} from '../../helpers/trace-details';
// §1 header — the Analytics FloatingPanel. The action cluster (Analytics button
// + options menu) only renders once trace data is loaded, which gotoTraceUntilLoaded
// guarantees by waiting for the root waterfall row.
//
// Not covered here: subheader summary (presentational → unit test), colour-by /
// options menu / trace-id copy (unit), Noz button (feature-flagged, lives in the
// filter bar). Resize is deferred — react-rnd's resize handles have no stable hook.
const trace = loadLargeTrace();
test.describe('Trace details — header analytics panel', () => {
test.beforeAll(async ({ playwright }) => {
const request = await playwright.request.newContext();
await seedTracesViaSeeder(request, trace.spans);
await request.dispose();
});
test.beforeEach(async ({ authedPage: page }) => {
await gotoTraceUntilLoaded(
page,
`/trace/${trace.traceId}`,
`cell-0-${trace.landmarks.root}`,
);
});
test('TC-02 the analytics panel can be dragged by its header', async ({
authedPage: page,
}) => {
await page.getByRole('button', { name: 'Analytics' }).click();
const panel = page.getByTestId('trace-analytics-panel');
await expect(panel).toBeVisible();
const zero = { x: 0, y: 0, width: 0, height: 0 };
const before = (await panel.boundingBox()) ?? zero;
const hb =
(await page.locator('.floating-panel__drag-handle').boundingBox()) ?? zero;
// Drag the header left + down.
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
await page.mouse.down();
await page.mouse.move(hb.x + hb.width / 2 - 120, hb.y + hb.height / 2 + 60, {
steps: 8,
});
await page.mouse.up();
// Panel shifted left.
await expect
.poll(async () => Math.round(((await panel.boundingBox()) ?? before).x))
.toBeLessThan(Math.round(before.x));
});
});

View File

@@ -0,0 +1,83 @@
import { test, expect } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
gotoTraceUntilLoaded,
hoverFlamegraphSpan,
loadLargeTrace,
resetTracePreferences,
seedTracesViaSeeder,
setPreviewFieldsPreference,
} from '../../helpers/trace-details';
// §6 — preview fields. A configured preview field appears as a row in the span
// hover card (SpanTooltipContent, testid span-hover-card-preview-<key>). The
// waterfall variant is covered at the unit/integration level; this spec keeps
// the flamegraph (canvas) case, which can't run in jsdom.
//
// Preview fields are a server-side, per-user preference, so each test seeds them
// via the API before navigating; afterAll resets them so the state doesn't leak
// into other specs run by the same admin user.
const trace = loadLargeTrace();
// The db landmark span carries db.system="redis"; seed db.system as a preview
// field so its value renders in the hover card.
const PREVIEW_FIELD = 'db.system';
const PREVIEW_VALUE = 'redis';
const PREVIEW_TESTID = `span-hover-card-preview-${PREVIEW_FIELD}`;
test.describe('Trace details — preview fields in the hover card', () => {
// Run serially in one worker: preview fields are a per-user preference, so
// the afterAll reset must not race a sibling test still using them on another
// worker (which intermittently wiped the preview row mid-test).
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ playwright }) => {
const request = await playwright.request.newContext();
await seedTracesViaSeeder(request, trace.spans);
await request.dispose();
});
test.afterAll(async ({ browser }) => {
// Reset prefs to defaults (afterAll can't use the authedPage fixture).
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
await resetTracePreferences(page);
await ctx.close();
});
test.beforeEach(async ({ authedPage: page }) => {
// Seed the preview field BEFORE navigating so the on-mount prefs fetch
// returns it and the hover card renders the row.
// db.system is a span ATTRIBUTE (fieldContext 'attribute', not 'span') —
// the flamegraph fetches fields selectively, so the wrong context means
// the bar's span wouldn't carry the value and the hover row wouldn't render.
await setPreviewFieldsPreference(page, [
{ name: PREVIEW_FIELD, fieldContext: 'attribute', fieldDataType: 'string' },
]);
await gotoTraceUntilLoaded(
page,
`/trace/${trace.traceId}`,
`cell-0-${trace.landmarks.root}`,
);
});
// FIXME: blocked by a frontend bug — the flamegraph fires its span fetch
// (POST /flamegraph) with selectFields = color-by only, before previewFields
// syncs into the store, and does NOT refetch when the preference lands. So the
// flamegraph span never carries the preview attribute (e.g. db.system) and its
// hover card can't render the row. Intermittent (passes only when prefs are
// cache-warm before the first fetch). Re-enable once the flamegraph
// gates/refetches on previewFields. See sprint task.
test.fixme('TC-01 flamegraph hover card shows the configured preview field', async ({
authedPage: page,
}) => {
const previewRow = page.getByTestId(PREVIEW_TESTID).first();
await expect(async () => {
await page.mouse.move(0, 0);
await hoverFlamegraphSpan(page, trace.landmarks.db);
await expect(previewRow).toBeVisible({ timeout: 1500 });
}).toPass({ timeout: 15_000 });
await expect(previewRow).toContainText(PREVIEW_VALUE);
});
});

View File

@@ -0,0 +1,50 @@
import { test, expect } from '../../fixtures/auth';
import {
gotoTraceUntilLoaded,
loadLargeTrace,
seedTracesViaSeeder,
} from '../../helpers/trace-details';
const trace = loadLargeTrace();
test.describe('Trace details — waterfall', () => {
test.beforeAll(async ({ playwright }) => {
const request = await playwright.request.newContext();
await seedTracesViaSeeder(request, trace.spans);
await request.dispose();
});
test('TC-01 deep-link ?spanId auto-selects the span and opens the drawer', async ({
authedPage: page,
}) => {
// Open the trace pre-pointed at a specific span via the URL, reloading
// until the waterfall renders (seed→query lag).
const errorSpan = trace.landmarks.errors[0];
await gotoTraceUntilLoaded(
page,
`/trace/${trace.traceId}?spanId=${errorSpan}`,
`cell-0-${errorSpan}`,
);
// the deep-linked span's row renders...
await expect(page.getByTestId(`cell-0-${errorSpan}`)).toBeVisible();
// ...and it auto-selects → the drawer is open (Overview tab is drawer-only)
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
});
test('TC-03 deep-linking a deeply-nested span auto-expands ancestors and scrolls it into view', async ({
authedPage: page,
}) => {
// deepLeaf sits ~34 levels down; rendering its row at all proves every
// ancestor auto-expanded and the waterfall scrolled it into view.
const deep = trace.landmarks.deepLeaf;
await gotoTraceUntilLoaded(
page,
`/trace/${trace.traceId}?spanId=${deep}`,
`cell-0-${deep}`,
);
await expect(page.getByTestId(`cell-0-${deep}`)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`spanId=${deep}`));
});
});