mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
56d8790892
commit
f06fd1ae9a
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user