feat(dashboard): focus any span as its own waterfall view

Every span row (except the current view root) gets a hover focus control
that re-roots the waterfall at that span: the subtree is re-based to
depth 0 and duration/counts/open state are recomputed over the subtree
only, so e.g. a 2-minute $session-replay nested in a year-long
$refresh-token becomes fully readable instead of a sliver. A breadcrumb
in the header climbs back to any ancestor. Focus resets when selecting
another trace.
This commit is contained in:
mantrakp04 2026-07-02 15:20:14 -07:00
parent 56d8790892
commit f06fd1ae9a
4 changed files with 182 additions and 45 deletions

View File

@ -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<string | null>(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<string, number>();
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);
}}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className={cn("h-2 w-2 rounded-[3px] shrink-0", spanColorClass(trace.root.span.spanType))} />
@ -352,12 +373,14 @@ export default function PageClient() {
<SpinnerGapIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && error == null && selectedTrace == null && (
{!loading && error == null && displayedTrace == null && (
<EmptyState title="Select a trace to see its waterfall." />
)}
{!loading && error == null && selectedTrace != null && (
{!loading && error == null && displayedTrace != null && (
<TraceWaterfall
trace={selectedTrace}
trace={displayedTrace.trace}
breadcrumb={displayedTrace.breadcrumb}
onFocusSpan={(spanId) => setFocusedSpanId(spanId)}
onSelectSpan={(span) => openDetail(span.raw)}
onSelectEvent={(event) => openDetail(event.raw)}
/>

View File

@ -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> = {}): 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");

View File

@ -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<Trace, "root"> {
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.

View File

@ -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 (
<div className="flex flex-col min-h-0 flex-1">
{/* Trace header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 shrink-0">
<div className="flex items-center gap-1.5 px-4 py-3 border-b border-border/50 shrink-0 min-w-0">
{breadcrumb.map((crumb) => (
<span key={crumb.spanId} className="flex items-center gap-1.5 min-w-0 shrink">
<button
className="font-mono text-xs text-muted-foreground hover:text-foreground truncate transition-colors hover:transition-none"
onClick={() => onFocusSpan(crumb.spanId)}
title={`Focus ${crumb.spanType}`}
>
{crumb.spanType}
</button>
<CaretRightIcon className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</span>
))}
<span className="font-mono text-sm font-semibold truncate">{trace.root.span.spanType}</span>
{trace.endMs == null ? (
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">open</Badge>
@ -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 (
<button
<div
key={`span-${span.id}-${index}`}
className="w-full grid gap-3 px-4 items-center h-8 border-b border-border/20 hover:bg-muted/30 transition-colors hover:transition-none text-left cursor-pointer"
className="group w-full grid gap-3 px-4 items-center h-8 border-b border-border/20 hover:bg-muted/30 transition-colors hover:transition-none text-left cursor-pointer"
style={{ gridTemplateColumns }}
onClick={() => onSelectSpan(span)}
>
@ -106,6 +127,18 @@ export function TraceWaterfall({
<span className={cn("font-mono text-[11px] truncate", isSystemSpanType(span.spanType) ? "text-muted-foreground" : "font-medium")}>
{span.spanType}
</span>
{!isViewRoot && (
<button
className="opacity-0 group-hover:opacity-100 shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.08] transition-opacity"
title="Focus this span"
onClick={(e) => {
e.stopPropagation();
onFocusSpan(span.id);
}}
>
<CornersOutIcon className="h-3 w-3" />
</button>
)}
</div>
<div className="relative h-4">
{[25, 50, 75].map((pct) => (
@ -123,13 +156,13 @@ export function TraceWaterfall({
<span className="font-mono text-[11px] text-muted-foreground text-right">
{open ? "open" : formatDuration((span.endMs ?? scaleEnd) - span.startMs)}
</span>
</button>
</div>
);
} else {
const { event } = row;
const leftPct = toPct(event.atMs);
return (
<button
<div
key={`event-${index}`}
className="w-full grid gap-3 px-4 items-center h-7 border-b border-border/20 hover:bg-muted/30 transition-colors hover:transition-none text-left cursor-pointer"
style={{ gridTemplateColumns }}
@ -151,7 +184,7 @@ export function TraceWaterfall({
<span className="font-mono text-[11px] text-muted-foreground/70 text-right">
+{formatDuration(event.atMs - scaleStart)}
</span>
</button>
</div>
);
}
})}