feat(dashboard): spans & events trace explorer page

New analytics page at /projects/[id]/analytics/spans-events: traces
master-detail (trace list + waterfall with spans and event markers,
depth-indented, chronologically interleaved) and an events tab with
type-breakdown chips. Data comes from default.spans/default.events via
the existing admin analytics query endpoint — no backend changes.

Trees are rebuilt client-side from root-first parent_span_ids; rows
re-parent to the nearest FETCHED ancestor so subtrees survive spans
falling outside the time window, with a cycle guard for hand-crafted
parent ids. Custom scope hides $-prefixed system spans/events; All
scope shows full session trees.
This commit is contained in:
mantrakp04 2026-07-02 14:29:16 -07:00
parent 939c230f50
commit 56d8790892
6 changed files with 962 additions and 0 deletions

View File

@ -0,0 +1,440 @@
"use client";
import { Badge, 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";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppEnabledGuard } from "../../app-enabled-guard";
import { PageLayout } from "../../page-layout";
import { useAdminApp } from "../../use-admin-app";
import {
AnalyticsEventLimitBanner,
ErrorDisplay,
RowDetailDialog,
VirtualizedFlatTable,
isDateValue,
parseClickHouseDate,
type RowData,
} from "../shared";
import {
buildTraces,
formatDuration,
isSystemSpanType,
type EventInput,
type SpanInput,
type Trace,
} from "./trace-utils";
import { TraceWaterfall, spanColorClass } from "./waterfall";
const SPANS_QUERY = `
SELECT id, span_type, span_started_at, span_ended_at, parent_span_ids, data,
user_id, refresh_token_id, session_replay_id, session_replay_segment_id
FROM default.spans
WHERE span_started_at >= now64(3) - INTERVAL {hours:UInt32} HOUR
ORDER BY span_started_at DESC
LIMIT 3000
`;
const EVENTS_QUERY = `
SELECT event_type, event_at, data, user_id, parent_span_ids,
refresh_token_id, session_replay_id, session_replay_segment_id
FROM default.events
WHERE event_at >= now64(3) - INTERVAL {hours:UInt32} HOUR
ORDER BY event_at DESC
LIMIT 3000
`;
const TIME_RANGES = [
{ label: "1h", hours: 1 },
{ label: "24h", hours: 24 },
{ label: "7d", hours: 168 },
{ label: "30d", hours: 720 },
] as const;
const EVENT_TABLE_COLUMNS = ["event_type", "event_at", "user_id", "parent_span_ids", "data"];
const CARD_CLASSES = "rounded-2xl border border-black/[0.06] bg-white/90 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl dark:border-white/[0.06] dark:bg-zinc-900/90";
function tryParseJson(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function parseSpanRow(row: Record<string, unknown>): SpanInput | null {
const id = row.id;
const spanType = row.span_type;
const startedAt = row.span_started_at;
if (typeof id !== "string" || typeof spanType !== "string" || !isDateValue(startedAt)) return null;
const endedAt = row.span_ended_at;
const parentSpanIds = Array.isArray(row.parent_span_ids)
? row.parent_span_ids.filter((value): value is string => typeof value === "string")
: [];
return {
id,
spanType,
startMs: parseClickHouseDate(startedAt).getTime(),
endMs: isDateValue(endedAt) ? parseClickHouseDate(endedAt).getTime() : null,
parentSpanIds,
// The spans data column is a JSON string; parse it so the detail dialog
// pretty-prints instead of showing an escaped blob.
raw: { ...row, data: tryParseJson(row.data) },
};
}
function parseEventRow(row: Record<string, unknown>): EventInput | null {
const eventType = row.event_type;
const eventAt = row.event_at;
if (typeof eventType !== "string" || !isDateValue(eventAt)) return null;
const parentSpanIds = Array.isArray(row.parent_span_ids)
? row.parent_span_ids.filter((value): value is string => typeof value === "string")
: [];
return {
eventType,
atMs: parseClickHouseDate(eventAt).getTime(),
parentSpanIds,
raw: row,
};
}
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<T extends string | number>({ value, onChange, options }: {
value: T,
onChange: (value: T) => void,
options: readonly { label: string, value: T }[],
}) {
return (
<div className="flex items-center rounded-lg bg-foreground/[0.04] p-0.5">
{options.map((option) => (
<button
key={String(option.value)}
onClick={() => onChange(option.value)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors hover:transition-none",
value === option.value
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
);
}
function EmptyState({ title, children }: { title: string, children?: React.ReactNode }) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 p-6 text-center">
<Typography variant="secondary" className="text-sm font-medium">{title}</Typography>
{children}
</div>
);
}
export default function PageClient() {
const adminApp = useAdminApp();
const [hours, setHours] = useState<number>(24);
const [tab, setTab] = useState<"traces" | "events">("traces");
const [scope, setScope] = useState<"custom" | "all">("custom");
const [search, setSearch] = useState("");
const [eventTypeFilter, setEventTypeFilter] = useState<string | null>(null);
const [selectedRootId, setSelectedRootId] = useState<string | null>(null);
const [detailRow, setDetailRow] = useState<RowData | null>(null);
const [spans, setSpans] = useState<SpanInput[]>([]);
const [events, setEvents] = useState<EventInput[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const requestSeqRef = useRef(0);
const loadData = useCallback(async () => {
const seq = ++requestSeqRef.current;
setLoading(true);
setError(null);
try {
const [spansResponse, eventsResponse] = await Promise.all([
adminApp.queryAnalytics({
query: SPANS_QUERY,
params: { hours },
include_all_branches: false,
timeout_ms: 30000,
}),
adminApp.queryAnalytics({
query: EVENTS_QUERY,
params: { hours },
include_all_branches: false,
timeout_ms: 30000,
}),
]);
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));
} catch (e) {
if (seq !== requestSeqRef.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (seq === requestSeqRef.current) setLoading(false);
}
}, [adminApp, hours]);
const lastLoadedHoursRef = useRef<number | null>(null);
useEffect(() => {
if (lastLoadedHoursRef.current === hours) return;
lastLoadedHoursRef.current = hours;
runAsynchronouslyWithAlert(loadData);
}, [hours, loadData]);
const scopedSpans = useMemo(
() => (scope === "custom" ? spans.filter((span) => !isSystemSpanType(span.spanType)) : spans),
[spans, scope],
);
const scopedEvents = useMemo(
() => (scope === "custom" ? events.filter((event) => !event.eventType.startsWith("$")) : events),
[events, scope],
);
const { traces } = useMemo(() => buildTraces(scopedSpans, scopedEvents), [scopedSpans, scopedEvents]);
const filteredTraces = useMemo(() => {
const needle = search.trim().toLowerCase();
if (needle === "") return traces;
return traces.filter((trace) => traceMatchesSearch(trace, needle));
}, [traces, search]);
const selectedTrace = useMemo<Trace | null>(
() => filteredTraces.find((trace) => trace.root.span.id === selectedRootId)
?? (filteredTraces.length > 0 ? filteredTraces[0] : null),
[filteredTraces, selectedRootId],
);
const eventTypeCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const event of scopedEvents) {
counts.set(event.eventType, (counts.get(event.eventType) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
}, [scopedEvents]);
const tableEvents = useMemo(() => {
const needle = search.trim().toLowerCase();
return scopedEvents.filter((event) =>
(eventTypeFilter == null || event.eventType === eventTypeFilter)
&& (needle === "" || event.eventType.toLowerCase().includes(needle)),
);
}, [scopedEvents, eventTypeFilter, search]);
const openDetail = useCallback((raw: Record<string, unknown>) => {
setDetailRow(raw);
}, []);
return (
<AppEnabledGuard appId="analytics">
<PageLayout
title="Spans & Events"
description="Custom spans and events from the SDK, correlated into traces."
fillWidth
containedHeight
actions={
<div className="flex items-center gap-2">
<Segmented value={hours} onChange={setHours} options={TIME_RANGES.map((range) => ({ label: range.label, value: range.hours }))} />
<Button
className="gap-1.5"
variant="secondary"
disabled={loading}
onClick={() => runAsynchronouslyWithAlert(loadData)}
>
<ArrowClockwiseIcon className="h-4 w-4" />
Refresh
</Button>
</div>
}
>
<AnalyticsEventLimitBanner />
{/* Tab + filter bar */}
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Segmented
value={tab}
onChange={(value) => setTab(value)}
options={[{ label: "Traces", value: "traces" as const }, { label: "Events", value: "events" as const }]}
/>
<Segmented
value={scope}
onChange={(value) => setScope(value)}
options={[{ label: "Custom", value: "custom" as const }, { label: "All (incl. system)", value: "all" as const }]}
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={tab === "traces" ? "Filter by span or event type…" : "Filter by event type…"}
className="h-8 w-56 text-xs"
/>
<span className="text-xs text-muted-foreground ml-auto">
{filteredTraces.length} {filteredTraces.length === 1 ? "trace" : "traces"} · {scopedSpans.length} {scopedSpans.length === 1 ? "span" : "spans"} · {scopedEvents.length} {scopedEvents.length === 1 ? "event" : "events"}
</span>
</div>
{tab === "traces" ? (
<div className="flex-1 min-h-0 flex gap-4">
{/* Trace list */}
<div className={cn(CARD_CLASSES, "w-80 shrink-0 flex flex-col min-h-0 overflow-hidden")}>
<div className="px-3 py-2 border-b border-border/50 shrink-0">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Traces</span>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading && (
<div className="flex items-center justify-center h-full">
<SpinnerGapIcon className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!loading && error != null && (
<ErrorDisplay error={error} onRetry={loadData} />
)}
{!loading && error == null && filteredTraces.length === 0 && (
<EmptyState title={search.trim() !== "" ? "No traces match the filter." : "No spans in this time range."}>
{search.trim() === "" && (
<pre className="text-left font-mono text-[11px] text-muted-foreground bg-muted/30 rounded-lg p-3 overflow-auto max-w-full">
{`const span = app.startSpan("checkout");\nawait span.trackEvent("item_added",\n { sku: "T-100" });\nawait span.end();`}
</pre>
)}
</EmptyState>
)}
{!loading && error == null && filteredTraces.map((trace) => {
const isSelected = selectedTrace != null && trace.root.span.id === selectedTrace.root.span.id;
const open = trace.endMs == null;
return (
<button
key={trace.root.span.id}
className={cn(
"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)}
>
<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))} />
<span className="font-mono text-xs font-semibold truncate">{trace.root.span.spanType}</span>
{open ? (
<Badge variant="secondary" className="ml-auto text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400 shrink-0">open</Badge>
) : (
<span className="ml-auto font-mono text-[11px] text-muted-foreground shrink-0">{formatDuration((trace.endMs ?? trace.latestMs) - trace.startMs)}</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted-foreground">
<span className="truncate">{new Date(trace.startMs).toLocaleString()}</span>
<span className="ml-auto shrink-0">{trace.spanCount}s · {trace.eventCount}e</span>
</div>
</button>
);
})}
</div>
</div>
{/* Waterfall */}
<div className={cn(CARD_CLASSES, "flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden")}>
{loading && (
<div className="flex items-center justify-center h-full">
<SpinnerGapIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && error == null && selectedTrace == null && (
<EmptyState title="Select a trace to see its waterfall." />
)}
{!loading && error == null && selectedTrace != null && (
<TraceWaterfall
trace={selectedTrace}
onSelectSpan={(span) => openDetail(span.raw)}
onSelectEvent={(event) => openDetail(event.raw)}
/>
)}
{!loading && error != null && (
<div className="flex items-center justify-center h-full">
<ErrorDisplay error={error} onRetry={loadData} />
</div>
)}
</div>
</div>
) : (
<div className={cn(CARD_CLASSES, "flex-1 min-h-0 flex flex-col overflow-hidden")}>
{/* Event type breakdown chips */}
{eventTypeCounts.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-border/50 shrink-0">
{eventTypeCounts.slice(0, 12).map(([eventType, count]) => (
<button
key={eventType}
onClick={() => setEventTypeFilter(eventTypeFilter === eventType ? null : eventType)}
className={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full border text-[11px] font-mono transition-colors hover:transition-none",
eventTypeFilter === eventType
? "border-foreground/30 bg-foreground/[0.08] text-foreground"
: "border-border/60 text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
>
<span>{eventType}</span>
<span className="font-semibold">{count}</span>
</button>
))}
{eventTypeFilter != null && (
<button
onClick={() => setEventTypeFilter(null)}
className="px-2 py-0.5 rounded-full text-[11px] text-muted-foreground hover:text-foreground"
>
Clear
</button>
)}
</div>
)}
{loading && (
<div className="flex items-center justify-center h-full">
<SpinnerGapIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && error != null && (
<div className="flex items-center justify-center h-full">
<ErrorDisplay error={error} onRetry={loadData} />
</div>
)}
{!loading && error == null && tableEvents.length === 0 && (
<EmptyState title="No events in this time range.">
<pre className="text-left font-mono text-[11px] text-muted-foreground bg-muted/30 rounded-lg p-3 overflow-auto max-w-full">
{`await app.trackEvent("signup_completed",\n { plan: "pro" });`}
</pre>
</EmptyState>
)}
{!loading && error == null && tableEvents.length > 0 && (
<VirtualizedFlatTable
columns={EVENT_TABLE_COLUMNS}
rows={tableEvents.map((event) => event.raw)}
onRowClick={(row) => openDetail(row)}
/>
)}
</div>
)}
<RowDetailDialog
row={detailRow}
columns={detailRow != null ? Object.keys(detailRow) : []}
open={detailRow != null}
onOpenChange={(open) => {
if (!open) setDetailRow(null);
}}
/>
</PageLayout>
</AppEnabledGuard>
);
}

View File

@ -0,0 +1,9 @@
import PageClient from "./page-client";
export const metadata = {
title: "Spans & Events",
};
export default function Page() {
return <PageClient />;
}

View File

@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import { buildTraces, flattenTrace, formatDuration, isSystemSpanType, type EventInput, type SpanInput } from "./trace-utils";
function span(id: string, opts: Partial<SpanInput> = {}): SpanInput {
return {
id,
spanType: opts.spanType ?? id,
startMs: opts.startMs ?? 1000,
endMs: opts.endMs === undefined ? 2000 : opts.endMs,
parentSpanIds: opts.parentSpanIds ?? [],
raw: opts.raw ?? {},
};
}
function event(eventType: string, opts: Partial<EventInput> = {}): EventInput {
return {
eventType,
atMs: opts.atMs ?? 1500,
parentSpanIds: opts.parentSpanIds ?? [],
raw: opts.raw ?? {},
};
}
describe("buildTraces", () => {
it("builds a tree from root-first parent chains and attaches events to the nearest fetched ancestor", () => {
const spans = [
span("cs-root", { startMs: 1000, endMs: 5000 }),
span("cs-child", { startMs: 2000, endMs: 4000, parentSpanIds: ["cs-root"] }),
span("cs-grandchild", { startMs: 2500, endMs: 3000, parentSpanIds: ["cs-root", "cs-child"] }),
];
const events = [
event("added", { atMs: 2600, parentSpanIds: ["cs-root", "cs-child", "cs-grandchild"] }),
event("started", { atMs: 1100, parentSpanIds: ["cs-root"] }),
];
const { traces, unattachedEvents } = buildTraces(spans, events);
expect(traces).toHaveLength(1);
expect(unattachedEvents).toHaveLength(0);
const trace = traces[0];
expect(trace.root.span.id).toBe("cs-root");
expect(trace.root.children.map((c) => c.span.id)).toEqual(["cs-child"]);
expect(trace.root.children[0].children.map((c) => c.span.id)).toEqual(["cs-grandchild"]);
expect(trace.root.events.map((e) => e.eventType)).toEqual(["started"]);
expect(trace.root.children[0].children[0].events.map((e) => e.eventType)).toEqual(["added"]);
expect(trace.spanCount).toBe(3);
expect(trace.eventCount).toBe(2);
expect(trace.startMs).toBe(1000);
expect(trace.endMs).toBe(5000);
});
it("re-parents to the nearest FETCHED ancestor when an intermediate span is missing", () => {
const spans = [
span("cs-root", { startMs: 1000 }),
// "cs-missing" is in the chain but was not fetched (outside time window)
span("cs-leaf", { startMs: 1200, parentSpanIds: ["cs-root", "cs-missing"] }),
];
const { traces } = buildTraces(spans, []);
expect(traces).toHaveLength(1);
expect(traces[0].root.span.id).toBe("cs-root");
expect(traces[0].root.children.map((c) => c.span.id)).toEqual(["cs-leaf"]);
});
it("treats spans with no fetched ancestor as separate traces, newest first", () => {
const spans = [
span("a", { startMs: 1000 }),
span("b", { startMs: 9000, parentSpanIds: ["not-fetched"] }),
];
const { traces } = buildTraces(spans, []);
expect(traces.map((t) => t.root.span.id)).toEqual(["b", "a"]);
});
it("marks a trace open (endMs null) when any span is open and tracks latestMs", () => {
const spans = [
span("root", { startMs: 1000, endMs: 8000 }),
span("open-child", { startMs: 2000, endMs: null, parentSpanIds: ["root"] }),
];
const events = [event("late", { atMs: 9500, parentSpanIds: ["root"] })];
const { traces } = buildTraces(spans, events);
expect(traces[0].endMs).toBeNull();
expect(traces[0].latestMs).toBe(9500);
});
it("returns events with no fetched ancestor as unattached", () => {
const { traces, unattachedEvents } = buildTraces(
[span("root")],
[event("orphan", { parentSpanIds: ["rti-not-fetched"] }), event("bare", { parentSpanIds: [] })],
);
expect(traces[0].eventCount).toBe(0);
expect(unattachedEvents.map((e) => e.eventType)).toEqual(["orphan", "bare"]);
});
it("survives hand-crafted parent cycles without infinite recursion", () => {
const spans = [
span("x", { parentSpanIds: ["y"] }),
span("y", { parentSpanIds: ["x"] }),
];
const { traces } = buildTraces(spans, []);
// Both list each other, so both have a "fetched parent" — but the cycle
// guard must still terminate and every span must appear exactly once.
const seen = new Set<string>();
for (const trace of traces) {
for (const row of flattenTrace(trace)) {
if (row.kind === "span") {
expect(seen.has(row.node.span.id)).toBe(false);
seen.add(row.node.span.id);
}
}
}
});
it("dedupes duplicate span ids, keeping the first occurrence", () => {
const spans = [
span("dup", { spanType: "kept", startMs: 1000 }),
span("dup", { spanType: "dropped", startMs: 2000 }),
];
const { traces } = buildTraces(spans, []);
expect(traces).toHaveLength(1);
expect(traces[0].root.span.spanType).toBe("kept");
});
});
describe("flattenTrace", () => {
it("interleaves a span's events and child spans chronologically at depth+1", () => {
const spans = [
span("root", { startMs: 1000, endMs: 9000 }),
span("child-early", { startMs: 2000, endMs: 3000, parentSpanIds: ["root"] }),
span("child-late", { startMs: 6000, endMs: 7000, parentSpanIds: ["root"] }),
];
const events = [event("between", { atMs: 4000, parentSpanIds: ["root"] })];
const { traces } = buildTraces(spans, events);
const rows = flattenTrace(traces[0]);
expect(rows.map((r) => (r.kind === "span" ? r.node.span.id : r.event.eventType))).toEqual([
"root",
"child-early",
"between",
"child-late",
]);
expect(rows.map((r) => (r.kind === "span" ? r.node.depth : r.depth))).toEqual([0, 1, 1, 1]);
});
});
describe("formatDuration", () => {
it("formats across magnitudes", () => {
expect(formatDuration(0.5)).toBe("<1ms");
expect(formatDuration(42)).toBe("42ms");
expect(formatDuration(2500)).toBe("2.5s");
expect(formatDuration(42_000)).toBe("42s");
expect(formatDuration(125_000)).toBe("2m 5s");
expect(formatDuration(3_600_000)).toBe("1h");
expect(formatDuration(90_000_000)).toBe("1d 1h");
expect(formatDuration(NaN)).toBe("—");
});
});
describe("isSystemSpanType", () => {
it("flags $-prefixed types as system", () => {
expect(isSystemSpanType("$session-replay")).toBe(true);
expect(isSystemSpanType("checkout")).toBe(false);
});
});

View File

@ -0,0 +1,190 @@
// Pure trace-tree construction for the Spans & Events page. Operates on
// already-parsed rows (epoch ms, not ClickHouse date strings) so the module
// stays dependency-free and unit-testable.
export type SpanInput = {
id: string,
spanType: string,
startMs: number,
endMs: number | null,
parentSpanIds: string[],
raw: Record<string, unknown>,
};
export type EventInput = {
eventType: string,
atMs: number,
parentSpanIds: string[],
raw: Record<string, unknown>,
};
export type TraceNode = {
span: SpanInput,
depth: number,
children: TraceNode[],
events: EventInput[],
};
export type Trace = {
root: TraceNode,
spanCount: number,
eventCount: number,
startMs: number,
endMs: number | null, // null while any span in the trace is still open
latestMs: number, // max timestamp observed anywhere in the trace
};
export type WaterfallRow =
| { kind: "span", node: TraceNode }
| { kind: "event", event: EventInput, depth: number };
export function isSystemSpanType(spanType: string): boolean {
return spanType.startsWith("$");
}
/**
* 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
* time window (or be filtered out) without orphaning their whole subtree.
* parent_span_ids is root-first, so we scan from the end.
*/
function nearestFetchedAncestor(parentSpanIds: string[], byId: Map<string, SpanInput>, selfId: string | null): string | null {
for (let i = parentSpanIds.length - 1; i >= 0; i--) {
const id = parentSpanIds[i];
if (id !== selfId && byId.has(id)) return id;
}
return null;
}
export function buildTraces(spans: SpanInput[], events: EventInput[]): { traces: Trace[], unattachedEvents: EventInput[] } {
const byId = new Map<string, SpanInput>();
for (const span of spans) {
if (!byId.has(span.id)) byId.set(span.id, span);
}
const childrenOf = new Map<string, SpanInput[]>();
const roots: SpanInput[] = [];
for (const span of byId.values()) {
const parentId = nearestFetchedAncestor(span.parentSpanIds, byId, span.id);
if (parentId == null) {
roots.push(span);
} else {
const siblings = childrenOf.get(parentId) ?? [];
siblings.push(span);
childrenOf.set(parentId, siblings);
}
}
const eventsOf = new Map<string, EventInput[]>();
const unattachedEvents: EventInput[] = [];
for (const event of events) {
const parentId = nearestFetchedAncestor(event.parentSpanIds, byId, null);
if (parentId == null) {
unattachedEvents.push(event);
} else {
const attached = eventsOf.get(parentId) ?? [];
attached.push(event);
eventsOf.set(parentId, attached);
}
}
const traces = roots.map((rootSpan) => {
// Cycle guard: hand-crafted parent ids could form a loop; a span may only
// appear once per trace.
const visited = new Set<string>();
const buildNode = (span: SpanInput, depth: number): TraceNode => {
visited.add(span.id);
const ownEvents = [...(eventsOf.get(span.id) ?? [])].sort((a, b) => a.atMs - b.atMs);
const childSpans = [...(childrenOf.get(span.id) ?? [])]
.filter((child) => !visited.has(child.id))
.sort((a, b) => a.startMs - b.startMs);
return {
span,
depth,
events: ownEvents,
children: childSpans.map((child) => buildNode(child, depth + 1)),
};
};
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;
});
traces.sort((a, b) => b.startMs - a.startMs);
return { traces, unattachedEvents };
}
/**
* Depth-first flattening for the waterfall: each span row is followed by its
* own events and child spans interleaved chronologically.
*/
export function flattenTrace(trace: Trace): WaterfallRow[] {
const rows: WaterfallRow[] = [];
const walk = (node: TraceNode) => {
rows.push({ kind: "span", node });
const items: { atMs: number, row: () => void }[] = [
...node.events.map((event) => ({ atMs: event.atMs, row: () => rows.push({ kind: "event", event, depth: node.depth + 1 }) })),
...node.children.map((child) => ({ atMs: child.span.startMs, row: () => walk(child) })),
];
items.sort((a, b) => a.atMs - b.atMs);
for (const item of items) item.row();
};
walk(trace.root);
return rows;
}
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "—";
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`;
if (ms < 3_600_000) {
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
if (ms < 86_400_000) {
const totalMinutes = Math.round(ms / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
}
const totalHours = Math.round(ms / 3_600_000);
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
return hours === 0 ? `${days}d` : `${days}d ${hours}h`;
}

View File

@ -0,0 +1,161 @@
"use client";
import { Badge } from "@/components/ui";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import { flattenTrace, formatDuration, isSystemSpanType, type EventInput, type SpanInput, type Trace } from "./trace-utils";
const SPAN_COLOR_CLASSES = [
"bg-blue-500",
"bg-cyan-500",
"bg-violet-500",
"bg-emerald-500",
"bg-amber-500",
"bg-rose-500",
"bg-sky-500",
"bg-fuchsia-500",
];
/** Stable color per span type so the same operation looks the same across traces. */
export function spanColorClass(spanType: string): string {
if (isSystemSpanType(spanType)) return "bg-slate-400/80 dark:bg-slate-500/80";
let hash = 0;
for (let i = 0; i < spanType.length; i++) {
hash = (hash * 31 + spanType.charCodeAt(i)) | 0;
}
return SPAN_COLOR_CLASSES[Math.abs(hash) % SPAN_COLOR_CLASSES.length];
}
const NAME_COLUMN = "minmax(200px, 260px)";
const DURATION_COLUMN = "76px";
export function TraceWaterfall({
trace,
onSelectSpan,
onSelectEvent,
}: {
trace: Trace,
onSelectSpan: (span: SpanInput) => void,
onSelectEvent: (event: EventInput) => void,
}) {
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 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 gridTemplateColumns = `${NAME_COLUMN} 1fr ${DURATION_COLUMN}`;
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">
<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>
) : (
<span className="font-mono text-xs text-muted-foreground">{formatDuration(trace.endMs - trace.startMs)}</span>
)}
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{trace.spanCount} {trace.spanCount === 1 ? "span" : "spans"} · {trace.eventCount} {trace.eventCount === 1 ? "event" : "events"} · started {new Date(trace.startMs).toLocaleString()}
</span>
</div>
{/* Time ruler */}
<div className="grid gap-3 px-4 py-1.5 border-b border-border/30 shrink-0" style={{ gridTemplateColumns }}>
<span className="font-mono text-[10px] text-muted-foreground">name</span>
<div className="relative h-4">
{[0, 25, 50, 75, 100].map((pct) => (
<span
key={pct}
className={cn(
"absolute top-0 font-mono text-[10px] text-muted-foreground/70",
pct === 100 ? "-translate-x-full" : pct === 0 ? "" : "-translate-x-1/2",
)}
style={{ left: `${pct}%` }}
>
{formatDuration((scaleSpan * pct) / 100)}
</span>
))}
</div>
<span className="font-mono text-[10px] text-muted-foreground text-right">duration</span>
</div>
{/* Rows */}
<div className="flex-1 min-h-0 overflow-auto">
{rows.map((row, index) => {
if (row.kind === "span") {
const { span } = row.node;
const open = span.endMs == null;
const leftPct = toPct(span.startMs);
const rightPct = open ? 100 : toPct(span.endMs ?? scaleEnd);
const widthPct = Math.max(rightPct - leftPct, 0.5);
return (
<button
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"
style={{ gridTemplateColumns }}
onClick={() => onSelectSpan(span)}
>
<div className="flex items-center gap-1.5 min-w-0" style={{ paddingLeft: `${row.node.depth * 14}px` }}>
<span className={cn("h-2 w-2 rounded-[3px] shrink-0", spanColorClass(span.spanType))} />
<span className={cn("font-mono text-[11px] truncate", isSystemSpanType(span.spanType) ? "text-muted-foreground" : "font-medium")}>
{span.spanType}
</span>
</div>
<div className="relative h-4">
{[25, 50, 75].map((pct) => (
<span key={pct} className="absolute inset-y-0 w-px bg-border/40" style={{ left: `${pct}%` }} />
))}
<span
className={cn(
"absolute inset-y-0.5 rounded-sm",
spanColorClass(span.spanType),
open && "[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]",
)}
style={{ left: `${leftPct}%`, width: `${widthPct}%`, minWidth: "3px" }}
/>
</div>
<span className="font-mono text-[11px] text-muted-foreground text-right">
{open ? "open" : formatDuration((span.endMs ?? scaleEnd) - span.startMs)}
</span>
</button>
);
} else {
const { event } = row;
const leftPct = toPct(event.atMs);
return (
<button
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 }}
onClick={() => onSelectEvent(event)}
>
<div className="flex items-center gap-1.5 min-w-0" style={{ paddingLeft: `${row.depth * 14}px` }}>
<span className="h-1.5 w-1.5 rotate-45 bg-foreground/50 shrink-0" />
<span className="font-mono text-[11px] text-muted-foreground truncate">{event.eventType}</span>
</div>
<div className="relative h-4">
{[25, 50, 75].map((pct) => (
<span key={pct} className="absolute inset-y-0 w-px bg-border/40" style={{ left: `${pct}%` }} />
))}
<span
className="absolute top-1/2 h-2 w-2 -translate-y-1/2 -translate-x-1/2 rotate-45 rounded-[2px] bg-foreground/60"
style={{ left: `${leftPct}%` }}
/>
</div>
<span className="font-mono text-[11px] text-muted-foreground/70 text-right">
+{formatDuration(event.atMs - scaleStart)}
</span>
</button>
);
}
})}
</div>
</div>
);
}

View File

@ -388,6 +388,7 @@ export const ALL_APPS_FRONTEND = {
href: "analytics",
navigationItems: [
{ displayName: "Tables", href: "./tables" },
{ displayName: "Spans & Events", href: "./spans-events" },
{ displayName: "Replays", href: "../session-replays" },
{ displayName: "Clickmaps", href: "./clickmaps" },
{ displayName: "Queries", href: "./queries" },