diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page-client.tsx index 953d119a7..9cbdbd5bb 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page-client.tsx @@ -21,11 +21,12 @@ import { buildTraces, formatDuration, isSystemSpanType, + rerootTrace, type EventInput, type SpanInput, type Trace, } from "./trace-utils"; -import { TraceWaterfall, spanColorClass } from "./waterfall"; +import { TraceWaterfall, spanColorClass, type TraceBreadcrumb } from "./waterfall"; const SPANS_QUERY = ` SELECT id, span_type, span_started_at, span_ended_at, parent_span_ids, data, @@ -222,6 +223,23 @@ export default function PageClient() { [filteredTraces, selectedRootId], ); + // Focus mode: view any span inside the selected trace as its own trace, + // re-scaled to the subtree. A stale/foreign id falls back to the full trace. + const [focusedSpanId, setFocusedSpanId] = useState(null); + const displayedTrace = useMemo<{ trace: Trace, breadcrumb: TraceBreadcrumb[] } | null>(() => { + if (selectedTrace == null) return null; + if (focusedSpanId != null && focusedSpanId !== selectedTrace.root.span.id) { + const rerooted = rerootTrace(selectedTrace, focusedSpanId); + if (rerooted != null) { + return { + trace: rerooted.trace, + breadcrumb: rerooted.path.slice(0, -1).map((node) => ({ spanId: node.span.id, spanType: node.span.spanType })), + }; + } + } + return { trace: selectedTrace, breadcrumb: [] }; + }, [selectedTrace, focusedSpanId]); + const eventTypeCounts = useMemo(() => { const counts = new Map(); for (const event of scopedEvents) { @@ -324,7 +342,10 @@ export default function PageClient() { "w-full text-left px-3 py-2 border-b border-border/30 hover:bg-muted/30 transition-colors hover:transition-none", isSelected && "bg-muted/50 hover:bg-muted/50", )} - onClick={() => setSelectedRootId(trace.root.span.id)} + onClick={() => { + setSelectedRootId(trace.root.span.id); + setFocusedSpanId(null); + }} >
@@ -352,12 +373,14 @@ export default function PageClient() {
)} - {!loading && error == null && selectedTrace == null && ( + {!loading && error == null && displayedTrace == null && ( )} - {!loading && error == null && selectedTrace != null && ( + {!loading && error == null && displayedTrace != null && ( setFocusedSpanId(spanId)} onSelectSpan={(span) => openDetail(span.raw)} onSelectEvent={(event) => openDetail(event.raw)} /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.test.ts index 5184ec52b..8e4105773 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildTraces, flattenTrace, formatDuration, isSystemSpanType, type EventInput, type SpanInput } from "./trace-utils"; +import { buildTraces, flattenTrace, formatDuration, isSystemSpanType, rerootTrace, traceNodePath, type EventInput, type SpanInput } from "./trace-utils"; function span(id: string, opts: Partial = {}): SpanInput { return { @@ -140,6 +140,56 @@ describe("flattenTrace", () => { }); }); +describe("rerootTrace", () => { + // Mirrors the real system hierarchy: $refresh-token (year-long) with a + // short $session-replay inside — focusing must re-scale to the subtree. + const spans = [ + span("rti-1", { spanType: "$refresh-token", startMs: 0, endMs: 365 * 86_400_000 }), + span("sri-1", { spanType: "$session-replay", startMs: 10_000, endMs: 70_000, parentSpanIds: ["rti-1"] }), + span("srsi-1", { spanType: "$session-replay-segment", startMs: 12_000, endMs: 50_000, parentSpanIds: ["rti-1", "sri-1"] }), + ]; + const events = [event("clicked", { atMs: 20_000, parentSpanIds: ["rti-1", "sri-1", "srsi-1"] })]; + + it("re-bases the focused span to depth 0 and recomputes aggregates over the subtree only", () => { + const { traces } = buildTraces(spans, events); + const rerooted = rerootTrace(traces[0], "sri-1"); + expect(rerooted).not.toBeNull(); + const { trace, path } = rerooted!; + expect(trace.root.span.id).toBe("sri-1"); + expect(trace.root.depth).toBe(0); + expect(trace.root.children[0].depth).toBe(1); + expect(trace.spanCount).toBe(2); + expect(trace.eventCount).toBe(1); + expect(trace.startMs).toBe(10_000); + expect(trace.endMs).toBe(70_000); + expect(path.map((node) => node.span.id)).toEqual(["rti-1", "sri-1"]); + }); + + it("returns null for a span id not in the trace", () => { + const { traces } = buildTraces(spans, events); + expect(rerootTrace(traces[0], "cs-not-here")).toBeNull(); + }); + + it("does not mutate the original trace", () => { + const { traces } = buildTraces(spans, events); + rerootTrace(traces[0], "srsi-1"); + expect(traces[0].root.depth).toBe(0); + expect(traces[0].root.children[0].children[0].depth).toBe(2); + expect(traces[0].spanCount).toBe(3); + }); +}); + +describe("traceNodePath", () => { + it("returns root-first inclusive path and null when absent", () => { + const { traces } = buildTraces( + [span("a"), span("b", { parentSpanIds: ["a"] })], + [], + ); + expect(traceNodePath(traces[0].root, "b")!.map((n) => n.span.id)).toEqual(["a", "b"]); + expect(traceNodePath(traces[0].root, "zzz")).toBeNull(); + }); +}); + describe("formatDuration", () => { it("formats across magnitudes", () => { expect(formatDuration(0.5)).toBe("<1ms"); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.ts index bc73bf4e2..4acc0e590 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.ts @@ -108,45 +108,76 @@ export function buildTraces(spans: SpanInput[], events: EventInput[]): { traces: }; const root = buildNode(rootSpan, 0); - - let spanCount = 0; - let eventCount = 0; - let startMs = Infinity; - let latestMs = -Infinity; - let hasOpenSpan = false; - let maxEndMs: number | null = null; - const stack: TraceNode[] = [root]; - while (stack.length > 0) { - const node = stack.pop(); - if (node == null) break; - spanCount++; - startMs = Math.min(startMs, node.span.startMs); - latestMs = Math.max(latestMs, node.span.startMs); - if (node.span.endMs == null) { - hasOpenSpan = true; - } else { - maxEndMs = maxEndMs == null ? node.span.endMs : Math.max(maxEndMs, node.span.endMs); - latestMs = Math.max(latestMs, node.span.endMs); - } - eventCount += node.events.length; - for (const event of node.events) latestMs = Math.max(latestMs, event.atMs); - stack.push(...node.children); - } - - return { - root, - spanCount, - eventCount, - startMs, - endMs: hasOpenSpan ? null : maxEndMs, - latestMs, - } satisfies Trace; + return { root, ...computeTraceAggregates(root) } satisfies Trace; }); traces.sort((a, b) => b.startMs - a.startMs); return { traces, unattachedEvents }; } +function computeTraceAggregates(root: TraceNode): Omit { + let spanCount = 0; + let eventCount = 0; + let startMs = Infinity; + let latestMs = -Infinity; + let hasOpenSpan = false; + let maxEndMs: number | null = null; + const stack: TraceNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop(); + if (node == null) break; + spanCount++; + startMs = Math.min(startMs, node.span.startMs); + latestMs = Math.max(latestMs, node.span.startMs); + if (node.span.endMs == null) { + hasOpenSpan = true; + } else { + maxEndMs = maxEndMs == null ? node.span.endMs : Math.max(maxEndMs, node.span.endMs); + latestMs = Math.max(latestMs, node.span.endMs); + } + eventCount += node.events.length; + for (const event of node.events) latestMs = Math.max(latestMs, event.atMs); + stack.push(...node.children); + } + return { + spanCount, + eventCount, + startMs, + endMs: hasOpenSpan ? null : maxEndMs, + latestMs, + }; +} + +/** Path from the trace root to the span (both inclusive), or null if absent. */ +export function traceNodePath(root: TraceNode, spanId: string): TraceNode[] | null { + if (root.span.id === spanId) return [root]; + for (const child of root.children) { + const childPath = traceNodePath(child, spanId); + if (childPath != null) return [root, ...childPath]; + } + return null; +} + +/** + * View a span inside a trace as its own trace: the subtree is re-based to + * depth 0 and the aggregates (duration, counts, open state) are recomputed + * over the subtree only, so the waterfall re-scales to the focused span. + * Returns the ancestor path alongside for breadcrumb rendering. + */ +export function rerootTrace(trace: Trace, spanId: string): { trace: Trace, path: TraceNode[] } | null { + const path = traceNodePath(trace.root, spanId); + if (path == null) return null; + const target = path[path.length - 1]; + const rebase = (node: TraceNode, depth: number): TraceNode => ({ + span: node.span, + depth, + events: node.events, + children: node.children.map((child) => rebase(child, depth + 1)), + }); + const root = rebase(target, 0); + return { trace: { root, ...computeTraceAggregates(root) }, path }; +} + /** * Depth-first flattening for the waterfall: each span row is followed by its * own events and child spans interleaved chronologically. diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/waterfall.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/waterfall.tsx index 86470c352..991214a4b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/waterfall.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/waterfall.tsx @@ -2,6 +2,7 @@ import { Badge } from "@/components/ui"; import { cn } from "@/lib/utils"; +import { CaretRightIcon, CornersOutIcon } from "@phosphor-icons/react"; import { useMemo } from "react"; import { flattenTrace, formatDuration, isSystemSpanType, type EventInput, type SpanInput, type Trace } from "./trace-utils"; @@ -29,12 +30,19 @@ export function spanColorClass(spanType: string): string { const NAME_COLUMN = "minmax(200px, 260px)"; const DURATION_COLUMN = "76px"; +export type TraceBreadcrumb = { spanId: string, spanType: string }; + export function TraceWaterfall({ trace, + breadcrumb, + onFocusSpan, onSelectSpan, onSelectEvent, }: { trace: Trace, + /** Ancestors of the current view root (trace root first); empty when unfocused. */ + breadcrumb: TraceBreadcrumb[], + onFocusSpan: (spanId: string) => void, onSelectSpan: (span: SpanInput) => void, onSelectEvent: (event: EventInput) => void, }) { @@ -53,7 +61,19 @@ export function TraceWaterfall({ return (
{/* Trace header */} -
+
+ {breadcrumb.map((crumb) => ( + + + + + ))} {trace.root.span.spanType} {trace.endMs == null ? ( open @@ -91,13 +111,14 @@ export function TraceWaterfall({ if (row.kind === "span") { const { span } = row.node; const open = span.endMs == null; + const isViewRoot = row.node.depth === 0; const leftPct = toPct(span.startMs); const rightPct = open ? 100 : toPct(span.endMs ?? scaleEnd); const widthPct = Math.max(rightPct - leftPct, 0.5); return ( - + )}
{[25, 50, 75].map((pct) => ( @@ -123,13 +156,13 @@ export function TraceWaterfall({ {open ? "open" : formatDuration((span.endMs ?? scaleEnd) - span.startMs)} - +
); } else { const { event } = row; const leftPct = toPct(event.atMs); return ( - +
); } })}