mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
939c230f50
commit
56d8790892
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Spans & Events",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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`;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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" },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user