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 9cbdbd5bb..fa5aceb11 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 @@ -1,6 +1,6 @@ "use client"; -import { Badge, Button, Input, Typography } from "@/components/ui"; +import { Button, Input, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import { ArrowClockwiseIcon, SpinnerGapIcon } from "@phosphor-icons/react"; @@ -17,16 +17,17 @@ import { parseClickHouseDate, type RowData, } from "../shared"; +import { SpanTreeList } from "./span-tree"; import { buildTraces, - formatDuration, isSystemSpanType, rerootTrace, + subtreeMatches, type EventInput, type SpanInput, type Trace, } from "./trace-utils"; -import { TraceWaterfall, spanColorClass, type TraceBreadcrumb } from "./waterfall"; +import { TraceWaterfall, type TraceBreadcrumb } from "./waterfall"; const SPANS_QUERY = ` SELECT id, span_type, span_started_at, span_ended_at, parent_span_ids, data, @@ -102,15 +103,6 @@ function parseEventRow(row: Record): EventInput | null { }; } -function traceMatchesSearch(trace: Trace, needle: string): boolean { - const walk = (node: Trace["root"]): boolean => { - if (node.span.spanType.toLowerCase().includes(needle)) return true; - if (node.events.some((event) => event.eventType.toLowerCase().includes(needle))) return true; - return node.children.some(walk); - }; - return walk(trace.root); -} - function Segmented({ value, onChange, options }: { value: T, onChange: (value: T) => void, @@ -158,6 +150,9 @@ export default function PageClient() { const [spans, setSpans] = useState([]); const [events, setEvents] = useState([]); + // "Now" reference for the waterfall/list: fixed at load time so intervals + // reaching into the future (e.g. $refresh-token expiry) render as ongoing. + const [nowMs, setNowMs] = useState(() => Date.now()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -185,6 +180,7 @@ export default function PageClient() { if (seq !== requestSeqRef.current) return; setSpans(spansResponse.result.map(parseSpanRow).filter((span): span is SpanInput => span != null)); setEvents(eventsResponse.result.map(parseEventRow).filter((event): event is EventInput => event != null)); + setNowMs(Date.now()); } catch (e) { if (seq !== requestSeqRef.current) return; setError(e instanceof Error ? e.message : String(e)); @@ -211,11 +207,11 @@ export default function PageClient() { const { traces } = useMemo(() => buildTraces(scopedSpans, scopedEvents), [scopedSpans, scopedEvents]); + const searchNeedle = search.trim().toLowerCase(); const filteredTraces = useMemo(() => { - const needle = search.trim().toLowerCase(); - if (needle === "") return traces; - return traces.filter((trace) => traceMatchesSearch(trace, needle)); - }, [traces, search]); + if (searchNeedle === "") return traces; + return traces.filter((trace) => subtreeMatches(trace.root, searchNeedle)); + }, [traces, searchNeedle]); const selectedTrace = useMemo( () => filteredTraces.find((trace) => trace.root.span.id === selectedRootId) @@ -332,37 +328,18 @@ export default function PageClient() { )} )} - {!loading && error == null && filteredTraces.map((trace) => { - const isSelected = selectedTrace != null && trace.root.span.id === selectedTrace.root.span.id; - const open = trace.endMs == null; - return ( - - ); - })} + {!loading && error == null && filteredTraces.length > 0 && ( + { + setSelectedRootId(rootId); + setFocusedSpanId(spanId === rootId ? null : spanId); + }} + /> + )} @@ -379,6 +356,7 @@ export default function PageClient() { {!loading && error == null && displayedTrace != null && ( setFocusedSpanId(spanId)} onSelectSpan={(span) => openDetail(span.raw)} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/span-tree.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/span-tree.tsx new file mode 100644 index 000000000..ada5853bf --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/span-tree.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { Badge } from "@/components/ui"; +import { cn } from "@/lib/utils"; +import { CaretRightIcon } from "@phosphor-icons/react"; +import { useState } from "react"; +import { formatDuration, subtreeMatches, type Trace, type TraceNode } from "./trace-utils"; +import { spanColorClass } from "./waterfall"; + +// Nodes shallower than this render expanded unless the user collapsed them. +const DEFAULT_EXPANDED_DEPTH = 3; + +function nodeMatches(node: TraceNode, needle: string): boolean { + return node.span.spanType.toLowerCase().includes(needle) + || node.events.some((event) => event.eventType.toLowerCase().includes(needle)); +} + +function DurationLabel({ startMs, endMs, nowMs }: { startMs: number, endMs: number | null, nowMs: number }) { + if (endMs == null) { + return open; + } + if (endMs > nowMs) { + // Interval reaches into the future (e.g. $refresh-token runs until its + // expiry) — show elapsed-so-far instead of the misleading total. + return ( + + {formatDuration(nowMs - startMs)} → + + ); + } + return {formatDuration(endMs - startMs)}; +} + +function TreeNode({ + node, + trace, + nowMs, + needle, + activeSpanId, + expandedOverrides, + onToggle, + onSelectSpan, +}: { + node: TraceNode, + trace: Trace, + nowMs: number, + needle: string, + activeSpanId: string | null, + expandedOverrides: Record, + onToggle: (spanId: string) => void, + onSelectSpan: (rootId: string, spanId: string) => void, +}) { + const isSearching = needle !== ""; + const visibleChildren = isSearching + ? node.children.filter((child) => subtreeMatches(child, needle)) + : node.children; + const hasChildren = visibleChildren.length > 0; + const expanded = expandedOverrides[node.span.id] + ?? (isSearching ? hasChildren : node.depth < DEFAULT_EXPANDED_DEPTH); + const isActive = node.span.id === activeSpanId; + const isRoot = node.depth === 0; + + return ( + <> +
onSelectSpan(trace.root.span.id, node.span.id)} + > + {hasChildren ? ( + + ) : ( + + )} + +
+
+ + {node.span.spanType} + + + {/* Root rows represent the whole trace, so they show trace + bounds — a derived $refresh-token span's own start can move + forward on token rotation and would read nonsensically. */} + + +
+ {isRoot && ( +
+ {new Date(trace.startMs).toLocaleString()} + {trace.spanCount}s · {trace.eventCount}e +
+ )} +
+
+ {expanded && visibleChildren.map((child) => ( + + ))} + + ); +} + +/** + * Hierarchical trace list: every span in every trace is reachable and + * individually selectable (selection focuses it in the waterfall). The first + * levels render expanded; deeper levels collapse behind carets. While + * searching, only matching subtrees render and ancestors force-expand. + */ +export function SpanTreeList({ + traces, + nowMs, + needle, + activeSpanId, + onSelectSpan, +}: { + traces: Trace[], + nowMs: number, + needle: string, + /** The span id currently shown as the waterfall root. */ + activeSpanId: string | null, + onSelectSpan: (rootId: string, spanId: string) => void, +}) { + const [expandedOverrides, setExpandedOverrides] = useState>({}); + + const handleToggle = (spanId: string) => { + setExpandedOverrides((prev) => { + const isSearching = needle !== ""; + const node = findNodeInTraces(traces, spanId); + const currentlyExpanded = prev[spanId] + ?? (node == null ? false : (isSearching ? node.children.length > 0 : node.depth < DEFAULT_EXPANDED_DEPTH)); + return { ...prev, [spanId]: !currentlyExpanded }; + }); + }; + + return ( +
+ {traces.map((trace) => ( + + ))} +
+ ); +} + +function findNodeInTraces(traces: Trace[], spanId: string): TraceNode | null { + const stack: TraceNode[] = traces.map((trace) => trace.root); + while (stack.length > 0) { + const node = stack.pop(); + if (node == null) break; + if (node.span.id === spanId) return node; + stack.push(...node.children); + } + return null; +} 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 8e4105773..ceada93d0 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, rerootTrace, traceNodePath, type EventInput, type SpanInput } from "./trace-utils"; +import { buildTraces, flattenTrace, formatDuration, isSystemSpanType, panViewWindow, rerootTrace, subtreeMatches, traceNodePath, zoomViewWindow, type EventInput, type SpanInput } from "./trace-utils"; function span(id: string, opts: Partial = {}): SpanInput { return { @@ -81,6 +81,16 @@ describe("buildTraces", () => { expect(traces[0].latestMs).toBe(9500); }); + it("widens startMs to include events that precede their span's start", () => { + // Events and replay chunks batch independently, so an attached event can + // predate the span row's own started_at. + const { traces } = buildTraces( + [span("root", { startMs: 5000, endMs: 9000 })], + [event("early", { atMs: 3000, parentSpanIds: ["root"] })], + ); + expect(traces[0].startMs).toBe(3000); + }); + it("returns events with no fetched ancestor as unattached", () => { const { traces, unattachedEvents } = buildTraces( [span("root")], @@ -190,8 +200,49 @@ describe("traceNodePath", () => { }); }); +describe("zoomViewWindow / panViewWindow", () => { + it("zooms in around the anchor point and keeps it fixed", () => { + const zoomed = zoomViewWindow({ start: 0, end: 1 }, 0.5, 0.5); + expect(zoomed.start).toBeCloseTo(0.25); + expect(zoomed.end).toBeCloseTo(0.75); + // Anchor at 0.5 of the view maps to the same absolute position after zoom. + const anchorAbsBefore = 0 + 0.5 * 1; + const anchorAbsAfter = zoomed.start + 0.5 * (zoomed.end - zoomed.start); + expect(anchorAbsAfter).toBeCloseTo(anchorAbsBefore); + }); + + it("clamps zoom-out to the full timeline and zoom position to [0, 1]", () => { + expect(zoomViewWindow({ start: 0.25, end: 0.75 }, 0.5, 10)).toEqual({ start: 0, end: 1 }); + const nearEdge = zoomViewWindow({ start: 0, end: 1 }, 0, 0.5); + expect(nearEdge.start).toBe(0); + expect(nearEdge.end).toBeCloseTo(0.5); + }); + + it("pans by a fraction of the view width and clamps at the edges", () => { + const panned = panViewWindow({ start: 0.2, end: 0.4 }, 0.5); + expect(panned.start).toBeCloseTo(0.3); + expect(panned.end).toBeCloseTo(0.5); + expect(panViewWindow({ start: 0.8, end: 1 }, 5)).toEqual({ start: 0.8, end: 1 }); + expect(panViewWindow({ start: 0, end: 0.2 }, -5)).toEqual({ start: 0, end: 0.2 }); + }); +}); + +describe("subtreeMatches", () => { + it("matches span types, event types, and descendants", () => { + const { traces } = buildTraces( + [span("a", { spanType: "checkout" }), span("b", { spanType: "payment", parentSpanIds: ["a"] })], + [event("card_declined", { parentSpanIds: ["a", "b"] })], + ); + expect(subtreeMatches(traces[0].root, "payment")).toBe(true); + expect(subtreeMatches(traces[0].root, "card_")).toBe(true); + expect(subtreeMatches(traces[0].root, "refund")).toBe(false); + expect(subtreeMatches(traces[0].root.children[0], "checkout")).toBe(false); + }); +}); + describe("formatDuration", () => { it("formats across magnitudes", () => { + expect(formatDuration(0)).toBe("0s"); expect(formatDuration(0.5)).toBe("<1ms"); expect(formatDuration(42)).toBe("42ms"); expect(formatDuration(2500)).toBe("2.5s"); 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 4acc0e590..ea45c9bec 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 @@ -42,6 +42,37 @@ export function isSystemSpanType(spanType: string): boolean { return spanType.startsWith("$"); } +/** True if the span, any of its events, or any descendant matches the needle (lowercase). */ +export function subtreeMatches(node: TraceNode, needle: string): boolean { + if (node.span.spanType.toLowerCase().includes(needle)) return true; + if (node.events.some((event) => event.eventType.toLowerCase().includes(needle))) return true; + return node.children.some((child) => subtreeMatches(child, needle)); +} + +/** + * Waterfall zoom viewport as fractions of the full trace timeline + * (0 = trace start, 1 = trace end). {start: 0, end: 1} means unzoomed. + */ +export type ViewWindow = { start: number, end: number }; + +const MIN_VIEW_SPAN = 1e-8; + +/** Zoom by `factor` (<1 zooms in) keeping the point at `anchorFrac` of the current view fixed. */ +export function zoomViewWindow(view: ViewWindow, anchorFrac: number, factor: number): ViewWindow { + const span = view.end - view.start; + const newSpan = Math.min(Math.max(span * factor, MIN_VIEW_SPAN), 1); + const anchorAbs = view.start + anchorFrac * span; + const start = Math.min(Math.max(anchorAbs - anchorFrac * newSpan, 0), 1 - newSpan); + return { start, end: start + newSpan }; +} + +/** Shift the view by `deltaFrac` of its own width, clamped to the timeline. */ +export function panViewWindow(view: ViewWindow, deltaFrac: number): ViewWindow { + const span = view.end - view.start; + const start = Math.min(Math.max(view.start + deltaFrac * span, 0), 1 - span); + return { start, end: start + span }; +} + /** * The parent a row renders under is its nearest ANCESTOR THAT WAS FETCHED, not * its literal last parent id — intermediate spans can fall outside the queried @@ -136,7 +167,12 @@ function computeTraceAggregates(root: TraceNode): Omit { latestMs = Math.max(latestMs, node.span.endMs); } eventCount += node.events.length; - for (const event of node.events) latestMs = Math.max(latestMs, event.atMs); + for (const event of node.events) { + // Events can precede their span's own start (events and replay chunks + // are batched independently) — widen the window so they stay on-scale. + startMs = Math.min(startMs, event.atMs); + latestMs = Math.max(latestMs, event.atMs); + } stack.push(...node.children); } return { @@ -199,6 +235,7 @@ export function flattenTrace(trace: Trace): WaterfallRow[] { export function formatDuration(ms: number): string { if (!Number.isFinite(ms) || ms < 0) return "—"; + if (ms === 0) return "0s"; if (ms < 1) return "<1ms"; if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`; 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 991214a4b..e61ad86a5 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 @@ -3,8 +3,18 @@ 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"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + flattenTrace, + formatDuration, + isSystemSpanType, + panViewWindow, + zoomViewWindow, + type EventInput, + type SpanInput, + type Trace, + type ViewWindow, +} from "./trace-utils"; const SPAN_COLOR_CLASSES = [ "bg-blue-500", @@ -29,17 +39,31 @@ export function spanColorClass(spanType: string): string { const NAME_COLUMN = "minmax(200px, 260px)"; const DURATION_COLUMN = "76px"; +const FULL_VIEW: ViewWindow = { start: 0, end: 1 }; export type TraceBreadcrumb = { spanId: string, spanType: string }; +function TimelineGridlines() { + return ( + <> + {[25, 50, 75].map((pct) => ( + + ))} + + ); +} + export function TraceWaterfall({ trace, + nowMs, breadcrumb, onFocusSpan, onSelectSpan, onSelectEvent, }: { trace: Trace, + /** "Current time" reference (when the data was loaded): the timeline never scales past it. */ + nowMs: number, /** Ancestors of the current view root (trace root first); empty when unfocused. */ breadcrumb: TraceBreadcrumb[], onFocusSpan: (spanId: string) => void, @@ -48,18 +72,58 @@ export function TraceWaterfall({ }) { const rows = useMemo(() => flattenTrace(trace), [trace]); - // Open traces render to the latest observed timestamp; open bars are drawn - // to the right edge with a fade, so the horizon only needs to be sane, not - // exact. The epsilon keeps zero-length traces from dividing by zero. + const [view, setView] = useState(FULL_VIEW); + const viewRef = useRef(view); + viewRef.current = view; + const rootSpanId = trace.root.span.id; + useEffect(() => { + setView(FULL_VIEW); + }, [rootSpanId]); + + // The scale is clamped to "now": a $refresh-token's expiry a year out must + // not compress everything that actually happened into a sliver. Future + // interval ends render as a fading stub instead. The epsilon keeps + // zero-length traces from dividing by zero. const scaleStart = trace.startMs; - const scaleEnd = Math.max(trace.endMs ?? trace.latestMs, scaleStart + 1); - const scaleSpan = scaleEnd - scaleStart; - const toPct = (ms: number) => Math.min(100, Math.max(0, ((ms - scaleStart) / scaleSpan) * 100)); + const scaleEnd = Math.max(Math.min(trace.endMs ?? trace.latestMs, nowMs), scaleStart + 1); + const totalSpanMs = scaleEnd - scaleStart; + const viewStartMs = scaleStart + view.start * totalSpanMs; + const viewSpanMs = Math.max((view.end - view.start) * totalSpanMs, 1e-9); + const toPct = (ms: number) => ((ms - viewStartMs) / viewSpanMs) * 100; + const isZoomed = view.start > 0 || view.end < 1; + + // cmd/ctrl+scroll zooms around the cursor; horizontal scroll pans while + // zoomed. Attached non-passively so preventDefault stops page zoom/scroll. + const containerRef = useRef(null); + const trackRef = useRef(null); + useEffect(() => { + const container = containerRef.current; + if (container == null) return; + const handleWheel = (e: WheelEvent) => { + const track = trackRef.current; + if (track == null) return; + const rect = track.getBoundingClientRect(); + if (rect.width <= 0) return; + const current = viewRef.current; + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const anchorFrac = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1); + // Functional update: wheel events can arrive faster than re-renders, + // and each step must compound on the previous one. + setView((prev) => zoomViewWindow(prev, anchorFrac, Math.exp(e.deltaY * 0.01))); + } else if ((current.start > 0 || current.end < 1) && Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + e.preventDefault(); + setView((prev) => panViewWindow(prev, e.deltaX / rect.width)); + } + }; + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => container.removeEventListener("wheel", handleWheel); + }, []); const gridTemplateColumns = `${NAME_COLUMN} 1fr ${DURATION_COLUMN}`; return ( -
+
{/* Trace header */}
{breadcrumb.map((crumb) => ( @@ -77,6 +141,10 @@ export function TraceWaterfall({ {trace.root.span.spanType} {trace.endMs == null ? ( open + ) : trace.endMs > nowMs ? ( + + {formatDuration(nowMs - trace.startMs)} → + ) : ( {formatDuration(trace.endMs - trace.startMs)} )} @@ -88,21 +156,37 @@ export function TraceWaterfall({ {/* Time ruler */}
name -
+
{[0, 25, 50, 75, 100].map((pct) => ( - {formatDuration((scaleSpan * pct) / 100)} + {formatDuration((viewStartMs - scaleStart) + (viewSpanMs * pct) / 100)} ))}
- duration + + {isZoomed ? ( + + ) : ( + "duration" + )} +
{/* Rows */} @@ -111,10 +195,16 @@ export function TraceWaterfall({ if (row.kind === "span") { const { span } = row.node; const open = span.endMs == null; + const runsIntoFuture = !open && (span.endMs ?? 0) > nowMs; 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); + const barEndMs = open || runsIntoFuture ? Math.min(nowMs, scaleEnd + viewSpanMs) : (span.endMs ?? scaleEnd); + const rawLeftPct = toPct(span.startMs); + const rawRightPct = toPct(barEndMs); + const barVisible = rawRightPct > 0 && rawLeftPct < 100; + const leftPct = Math.max(rawLeftPct, 0); + const rightPct = Math.min(rawRightPct, 100); + const widthPct = Math.max(rightPct - leftPct, 0.4); + const fades = open || runsIntoFuture; return (
)}
-
- {[25, 50, 75].map((pct) => ( - - ))} - +
+ + {barVisible && ( + + )}
- {open ? "open" : formatDuration((span.endMs ?? scaleEnd) - span.startMs)} + {open + ? "open" + : runsIntoFuture + ? {formatDuration(nowMs - span.startMs)} → + : formatDuration((span.endMs ?? scaleEnd) - span.startMs)}
); @@ -172,14 +267,14 @@ export function TraceWaterfall({ {event.eventType}
-
- {[25, 50, 75].map((pct) => ( - - ))} - +
+ + {leftPct >= 0 && leftPct <= 100 && ( + + )}
+{formatDuration(event.atMs - scaleStart)}