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 new file mode 100644 index 000000000..953d119a7 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page-client.tsx @@ -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): 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): 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({ value, onChange, options }: { + value: T, + onChange: (value: T) => void, + options: readonly { label: string, value: T }[], +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +function EmptyState({ title, children }: { title: string, children?: React.ReactNode }) { + return ( +
+ {title} + {children} +
+ ); +} + +export default function PageClient() { + const adminApp = useAdminApp(); + + const [hours, setHours] = useState(24); + const [tab, setTab] = useState<"traces" | "events">("traces"); + const [scope, setScope] = useState<"custom" | "all">("custom"); + const [search, setSearch] = useState(""); + const [eventTypeFilter, setEventTypeFilter] = useState(null); + const [selectedRootId, setSelectedRootId] = useState(null); + const [detailRow, setDetailRow] = useState(null); + + const [spans, setSpans] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(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( + () => filteredTraces.find((trace) => trace.root.span.id === selectedRootId) + ?? (filteredTraces.length > 0 ? filteredTraces[0] : null), + [filteredTraces, selectedRootId], + ); + + const eventTypeCounts = useMemo(() => { + const counts = new Map(); + 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) => { + setDetailRow(raw); + }, []); + + return ( + + + ({ label: range.label, value: range.hours }))} /> + + + } + > + + + {/* Tab + filter bar */} +
+ setTab(value)} + options={[{ label: "Traces", value: "traces" as const }, { label: "Events", value: "events" as const }]} + /> + setScope(value)} + options={[{ label: "Custom", value: "custom" as const }, { label: "All (incl. system)", value: "all" as const }]} + /> + setSearch(e.target.value)} + placeholder={tab === "traces" ? "Filter by span or event type…" : "Filter by event type…"} + className="h-8 w-56 text-xs" + /> + + {filteredTraces.length} {filteredTraces.length === 1 ? "trace" : "traces"} · {scopedSpans.length} {scopedSpans.length === 1 ? "span" : "spans"} · {scopedEvents.length} {scopedEvents.length === 1 ? "event" : "events"} + +
+ + {tab === "traces" ? ( +
+ {/* Trace list */} +
+
+ Traces +
+
+ {loading && ( +
+ +
+ )} + {!loading && error != null && ( + + )} + {!loading && error == null && filteredTraces.length === 0 && ( + + {search.trim() === "" && ( +
+                        {`const span = app.startSpan("checkout");\nawait span.trackEvent("item_added",\n  { sku: "T-100" });\nawait span.end();`}
+                      
+ )} +
+ )} + {!loading && error == null && filteredTraces.map((trace) => { + const isSelected = selectedTrace != null && trace.root.span.id === selectedTrace.root.span.id; + const open = trace.endMs == null; + return ( + + ); + })} +
+
+ + {/* Waterfall */} +
+ {loading && ( +
+ +
+ )} + {!loading && error == null && selectedTrace == null && ( + + )} + {!loading && error == null && selectedTrace != null && ( + openDetail(span.raw)} + onSelectEvent={(event) => openDetail(event.raw)} + /> + )} + {!loading && error != null && ( +
+ +
+ )} +
+
+ ) : ( +
+ {/* Event type breakdown chips */} + {eventTypeCounts.length > 0 && ( +
+ {eventTypeCounts.slice(0, 12).map(([eventType, count]) => ( + + ))} + {eventTypeFilter != null && ( + + )} +
+ )} + {loading && ( +
+ +
+ )} + {!loading && error != null && ( +
+ +
+ )} + {!loading && error == null && tableEvents.length === 0 && ( + +
+                  {`await app.trackEvent("signup_completed",\n  { plan: "pro" });`}
+                
+
+ )} + {!loading && error == null && tableEvents.length > 0 && ( + event.raw)} + onRowClick={(row) => openDetail(row)} + /> + )} +
+ )} + + { + if (!open) setDetailRow(null); + }} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page.tsx new file mode 100644 index 000000000..b019cf9c1 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page.tsx @@ -0,0 +1,9 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Spans & Events", +}; + +export default function Page() { + return ; +} 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 new file mode 100644 index 000000000..5184ec52b --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.test.ts @@ -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 { + 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 { + 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(); + 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); + }); +}); 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 new file mode 100644 index 000000000..bc73bf4e2 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/trace-utils.ts @@ -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, +}; + +export type EventInput = { + eventType: string, + atMs: number, + parentSpanIds: string[], + raw: Record, +}; + +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, 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(); + for (const span of spans) { + if (!byId.has(span.id)) byId.set(span.id, span); + } + + const childrenOf = new Map(); + 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(); + 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(); + + 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`; +} 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 new file mode 100644 index 000000000..86470c352 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/waterfall.tsx @@ -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 ( +
+ {/* Trace header */} +
+ {trace.root.span.spanType} + {trace.endMs == null ? ( + open + ) : ( + {formatDuration(trace.endMs - trace.startMs)} + )} + + {trace.spanCount} {trace.spanCount === 1 ? "span" : "spans"} · {trace.eventCount} {trace.eventCount === 1 ? "event" : "events"} · started {new Date(trace.startMs).toLocaleString()} + +
+ + {/* Time ruler */} +
+ name +
+ {[0, 25, 50, 75, 100].map((pct) => ( + + {formatDuration((scaleSpan * pct) / 100)} + + ))} +
+ duration +
+ + {/* Rows */} +
+ {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 ( + + ); + } else { + const { event } = row; + const leftPct = toPct(event.atMs); + return ( + + ); + } + })} +
+
+ ); +} diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 7f592e49d..8c5d5a4b1 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -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" },