From 798bca97613392c489b28712d0ab3ffdf81c5533 Mon Sep 17 00:00:00 2001 From: kemal Date: Fri, 2 Oct 2026 13:20:26 +0100 Subject: [PATCH] feat: first draft of tracing ui primitives --- apps/design-system/AGENTS.md | 9 + apps/design-system/CLAUDE.md | 1 + apps/design-system/config/docs.ts | 5 + .../content/docs/ui-patterns/tracing.mdx | 40 + .../default/example/trace-inspector-demo.tsx | 45 + .../default/example/trace-minimap-demo.tsx | 40 + .../default/example/trace-ruler-demo.tsx | 66 ++ .../registry/default/example/trace-sample.ts | 347 ++++++++ .../default/example/trace-span-bar-demo.tsx | 104 +++ .../default/example/trace-tree-cell-demo.tsx | 35 + .../example/trace-waterfall-compact-demo.tsx | 21 + .../default/example/trace-waterfall-demo.tsx | 48 ++ .../example/trace-waterfall-url-sync-demo.tsx | 54 ++ apps/design-system/registry/examples.ts | 46 ++ packages/ui-patterns/package.json | 61 ++ packages/ui-patterns/src/Trace/Bar.tsx | 142 ++++ packages/ui-patterns/src/Trace/Cell.tsx | 119 +++ packages/ui-patterns/src/Trace/Header.tsx | 145 ++++ packages/ui-patterns/src/Trace/Inspector.tsx | 379 +++++++++ packages/ui-patterns/src/Trace/Minimap.tsx | 226 +++++ packages/ui-patterns/src/Trace/Trace.tsx | 172 ++++ packages/ui-patterns/src/Trace/Waterfall.tsx | 294 +++++++ .../ui-patterns/src/Trace/hooks/useBrush.ts | 250 ++++++ .../src/Trace/hooks/useKeyboard.ts | 44 + .../ui-patterns/src/Trace/hooks/useSpan.ts | 11 + .../src/Trace/hooks/useTimeScale.ts | 48 ++ .../src/Trace/hooks/useVisibleRows.ts | 27 + packages/ui-patterns/src/Trace/index.ts | 49 ++ packages/ui-patterns/src/Trace/types.ts | 189 +++++ packages/ui-patterns/src/Trace/utils.test.ts | 428 ++++++++++ packages/ui-patterns/src/Trace/utils.ts | 780 ++++++++++++++++++ pnpm-lock.yaml | 3 + 32 files changed, 4228 insertions(+) create mode 100644 apps/design-system/AGENTS.md create mode 100644 apps/design-system/CLAUDE.md create mode 100644 apps/design-system/content/docs/ui-patterns/tracing.mdx create mode 100644 apps/design-system/registry/default/example/trace-inspector-demo.tsx create mode 100644 apps/design-system/registry/default/example/trace-minimap-demo.tsx create mode 100644 apps/design-system/registry/default/example/trace-ruler-demo.tsx create mode 100644 apps/design-system/registry/default/example/trace-sample.ts create mode 100644 apps/design-system/registry/default/example/trace-span-bar-demo.tsx create mode 100644 apps/design-system/registry/default/example/trace-tree-cell-demo.tsx create mode 100644 apps/design-system/registry/default/example/trace-waterfall-compact-demo.tsx create mode 100644 apps/design-system/registry/default/example/trace-waterfall-demo.tsx create mode 100644 apps/design-system/registry/default/example/trace-waterfall-url-sync-demo.tsx create mode 100644 packages/ui-patterns/src/Trace/Bar.tsx create mode 100644 packages/ui-patterns/src/Trace/Cell.tsx create mode 100644 packages/ui-patterns/src/Trace/Header.tsx create mode 100644 packages/ui-patterns/src/Trace/Inspector.tsx create mode 100644 packages/ui-patterns/src/Trace/Minimap.tsx create mode 100644 packages/ui-patterns/src/Trace/Trace.tsx create mode 100644 packages/ui-patterns/src/Trace/Waterfall.tsx create mode 100644 packages/ui-patterns/src/Trace/hooks/useBrush.ts create mode 100644 packages/ui-patterns/src/Trace/hooks/useKeyboard.ts create mode 100644 packages/ui-patterns/src/Trace/hooks/useSpan.ts create mode 100644 packages/ui-patterns/src/Trace/hooks/useTimeScale.ts create mode 100644 packages/ui-patterns/src/Trace/hooks/useVisibleRows.ts create mode 100644 packages/ui-patterns/src/Trace/index.ts create mode 100644 packages/ui-patterns/src/Trace/types.ts create mode 100644 packages/ui-patterns/src/Trace/utils.test.ts create mode 100644 packages/ui-patterns/src/Trace/utils.ts diff --git a/apps/design-system/AGENTS.md b/apps/design-system/AGENTS.md new file mode 100644 index 00000000000..643577dfaef --- /dev/null +++ b/apps/design-system/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/design-system/CLAUDE.md b/apps/design-system/CLAUDE.md new file mode 100644 index 00000000000..43c994c2d36 --- /dev/null +++ b/apps/design-system/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index 4ad98ae7c13..e91140d52aa 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -70,6 +70,11 @@ export const docsConfig: DocsConfig = { href: '/docs/ui-patterns/charts', items: [], }, + { + title: 'Tracing', + href: '/docs/ui-patterns/tracing', + items: [], + }, { title: 'Connect Interstitials', href: '/docs/ui-patterns/connect-interstitials', diff --git a/apps/design-system/content/docs/ui-patterns/tracing.mdx b/apps/design-system/content/docs/ui-patterns/tracing.mdx new file mode 100644 index 00000000000..999a01918ef --- /dev/null +++ b/apps/design-system/content/docs/ui-patterns/tracing.mdx @@ -0,0 +1,40 @@ +--- +title: Tracing +description: Composable primitives for distributed-trace views built on the shared design system +--- + +```tsx +import { Trace } from 'ui-patterns/Trace' +``` + +## Ruler + + + +## Bar and Markers + + + +## Cell + + + +## Minimap + + + +## Inspector + + + +## Composition + + + +### Compact, read-only embed + + + +### Selection synced to the URL + + diff --git a/apps/design-system/registry/default/example/trace-inspector-demo.tsx b/apps/design-system/registry/default/example/trace-inspector-demo.tsx new file mode 100644 index 00000000000..e158f89eb06 --- /dev/null +++ b/apps/design-system/registry/default/example/trace-inspector-demo.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' +import { ToggleGroup, ToggleGroupItem } from 'ui' +import { Trace } from 'ui-patterns/Trace' + +import { createSampleTrace } from './trace-sample' + +const sample = createSampleTrace() + +const OPTIONS = [ + { id: 'none', label: 'None' }, + { id: 'span-0001', label: 'Root' }, + { id: 'span-0008', label: 'Error' }, + { id: 'span-0016', label: 'Running' }, +] + +export default function TraceInspectorDemo() { + const [selectedId, setSelectedId] = useState('span-0008') + + return ( +
+ setSelectedId(value)} + allowDeselect={false} + aria-label="Inspected span" + > + {OPTIONS.map((option) => ( + + {option.label} + + ))} + + setSelectedId(id ?? 'none')} + > + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/trace-minimap-demo.tsx b/apps/design-system/registry/default/example/trace-minimap-demo.tsx new file mode 100644 index 00000000000..faf9df4e16b --- /dev/null +++ b/apps/design-system/registry/default/example/trace-minimap-demo.tsx @@ -0,0 +1,40 @@ +import { useMemo, useState } from 'react' +import { Slider, Toggle } from 'ui' +import { Trace, type TimeWindow } from 'ui-patterns/Trace' + +import { createSampleTrace } from './trace-sample' + +export default function TraceMinimapDemo() { + const [isLarge, setIsLarge] = useState(false) + const sample = useMemo(() => createSampleTrace({ spanCount: isLarge ? 5000 : 0 }), [isLarge]) + const [window, setWindow] = useState(undefined) + + const start = sample.spans[0].startMs + const bounds: TimeWindow = [start, sample.nowMs] + const current = window ?? bounds + + return ( +
+
+ + 5,000 spans (density mode) + + +
+ + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/trace-ruler-demo.tsx b/apps/design-system/registry/default/example/trace-ruler-demo.tsx new file mode 100644 index 00000000000..d193716734c --- /dev/null +++ b/apps/design-system/registry/default/example/trace-ruler-demo.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { Slider } from 'ui' +import { Trace, type Span, type TimeWindow } from 'ui-patterns/Trace' + +function traceOf(durationMs: number): { spans: Span[] } { + return { + spans: [ + { + id: 'root', + parentId: null, + traceId: 'ruler', + name: 'root', + serviceName: 'svc', + kind: 'server', + startMs: 0, + endMs: durationMs, + status: 'ok', + attributes: {}, + events: [], + links: [], + }, + ], + } +} + +const trace = traceOf(1000) + +export default function TraceRulerDemo() { + const [window, setWindow] = useState([0, 1000]) + + return ( +
+ + + + + + + + +
+ {[ + { durationMs: 2, label: 'Sub-millisecond' }, + { durationMs: 412, label: 'Request-sized' }, + { durationMs: 95 * 60_000, label: 'Minutes' }, + ].map(({ durationMs, label }) => ( + + + {label} + + + ))} +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/trace-sample.ts b/apps/design-system/registry/default/example/trace-sample.ts new file mode 100644 index 00000000000..149a95904bf --- /dev/null +++ b/apps/design-system/registry/default/example/trace-sample.ts @@ -0,0 +1,347 @@ +import type { Span, SpanKind, SpanStatus } from 'ui-patterns/Trace' + +export interface SampleTraceOptions { + spanCount?: number + seed?: number + startMs?: number + includeOrphan?: boolean + includeRunning?: boolean + includeZeroDuration?: boolean + includeClockSkew?: boolean +} + +export interface SampleTrace { + traceId: string + spans: Span[] + nowMs: number +} + +export const SAMPLE_TRACE_ID = 'trace-4f2a9c1e7b3d5a60' +export const SAMPLE_TRACE_START_MS = Date.UTC(2026, 0, 15, 9, 30, 0) + +function mulberry32(seed: number) { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function span( + id: string, + parentId: string | null, + name: string, + serviceName: string, + kind: SpanKind, + startMs: number, + endMs: number | null, + status: SpanStatus, + base: number, + extra: Partial> = {} +): Span { + return { + id, + parentId, + traceId: SAMPLE_TRACE_ID, + name, + serviceName, + kind, + startMs: base + startMs, + endMs: endMs === null ? null : base + endMs, + status, + attributes: extra.attributes ?? {}, + events: (extra.events ?? []).map((event) => ({ ...event, timeMs: base + event.timeMs })), + links: extra.links ?? [], + } +} + +function baseSpans(base: number, options: Required): Span[] { + const spans: Span[] = [ + span('span-0001', null, 'GET /api/orders', 'api-gateway', 'server', 0, 412, 'ok', base, { + attributes: { + 'http.method': 'GET', + 'http.route': '/api/orders', + 'http.status_code': 200, + 'http.url': 'https://api.example.com/api/orders?limit=50', + 'user.id': 'usr_8f3k2', + 'net.peer.ip': '10.0.3.14', + 'request.id': 'req_01HZX4M2', + }, + events: [ + { timeMs: 0.3, name: 'request.received' }, + { timeMs: 12, name: 'log', attributes: { 'log.severity': 'INFO', message: 'cache miss' } }, + ], + }), + span('span-0002', 'span-0001', 'authenticate', 'auth-service', 'client', 2, 38, 'ok', base, { + attributes: { 'auth.method': 'jwt', 'auth.cached': false, 'auth.token_expiry': 1768556400 }, + }), + span('span-0003', 'span-0002', 'jwt.verify', 'auth-service', 'internal', 4, 31, 'ok', base, { + attributes: { 'jwt.alg': 'RS256', 'jwt.kid': 'k-2026-01' }, + }), + span( + 'span-0004', + 'span-0001', + 'cache.get orders:usr_8f3k2', + 'redis-cache', + 'client', + 40, + 43, + 'ok', + base, + { attributes: { 'db.system': 'redis', 'cache.hit': false } } + ), + span('span-0005', 'span-0001', 'SELECT orders', 'postgres', 'client', 45, 210, 'ok', base, { + attributes: { + 'db.system': 'postgresql', + 'db.name': 'app', + 'db.statement': + 'SELECT id, total, status FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50', + 'db.rows': 50, + }, + }), + span('span-0006', 'span-0005', 'pg.connect', 'postgres', 'internal', 45, 52, 'ok', base, { + attributes: { 'pool.waiting': 3, 'pool.size': 20 }, + }), + span('span-0007', 'span-0005', 'pg.execute', 'postgres', 'internal', 52, 206, 'ok', base, { + events: [{ timeMs: 180, name: 'first_row' }], + }), + span( + 'span-0008', + 'span-0001', + 'POST /payments/authorize', + 'payment-service', + 'client', + 215, + 398, + 'error', + base, + { + attributes: { + 'http.method': 'POST', + 'http.status_code': 502, + 'error.message': 'upstream timeout after 150ms', + 'retry.count': 1, + 'payment.provider_response': { + code: 'TIMEOUT', + attempt: 2, + details: { region: 'eu-west-1', latency_ms: 150 }, + }, + }, + events: [ + { + timeMs: 395, + name: 'exception', + attributes: { + 'exception.type': 'UpstreamTimeoutError', + 'exception.message': 'upstream timeout after 150ms', + 'exception.stacktrace': + 'UpstreamTimeoutError: upstream timeout after 150ms\n at charge (payments/provider.ts:88:11)\n at authorize (payments/authorize.ts:42:5)', + }, + }, + ], + links: [{ traceId: 'trace-91cc0e4d2a7f1b38', spanId: 'span-77aa' }], + } + ), + span( + 'span-0009', + 'span-0008', + 'authorize', + 'payment-service', + 'server', + 218, + 392, + 'error', + base, + { + attributes: { 'payment.amount_cents': 12900, 'payment.currency': 'EUR' }, + } + ), + span('span-0010', 'span-0009', 'fraud.check', 'fraud-service', 'client', 220, 268, 'ok', base, { + attributes: { 'fraud.model': 'v7', 'fraud.score': 0.12 }, + events: [{ timeMs: 265, name: 'score.computed', attributes: { 'fraud.score': 0.12 } }], + }), + span( + 'span-0011', + 'span-0009', + 'provider.charge', + 'payment-service', + 'client', + 270, + 390, + 'error', + base, + { attributes: { 'provider.name': 'cards-eu', 'provider.timeout_ms': 150 } } + ), + span( + 'span-0012', + 'span-0011', + 'http.request', + 'provider-client', + 'client', + options.includeClockSkew ? 268 : 271, + 389, + 'error', + base, + { + attributes: { + 'http.method': 'POST', + 'http.url': 'https://cards-eu.example.net/v1/charges', + }, + } + ), + span( + 'span-0014', + 'span-0001', + 'render.response', + 'api-gateway', + 'internal', + 402, + 411, + 'ok', + base, + { + attributes: { 'response.bytes': 18422, 'response.format': 'json' }, + } + ), + ] + + if (options.includeZeroDuration) { + spans.push( + span('span-0013', 'span-0001', 'cache.set', 'redis-cache', 'client', 400, 400, 'ok', base, { + attributes: { 'db.system': 'redis', 'cache.ttl_seconds': 60 }, + }) + ) + } + + if (options.includeOrphan) { + spans.push( + span( + 'span-0015', + 'span-missing-parent', + 'background.job', + 'worker-service', + 'consumer', + 300, + 360, + 'ok', + base, + { attributes: { 'messaging.system': 'pgmq', 'messaging.destination': 'order_events' } } + ) + ) + } + + if (options.includeRunning) { + spans.push( + span( + 'span-0016', + 'span-0001', + 'webhook.dispatch', + 'webhook-service', + 'producer', + 405, + null, + 'unset', + base, + { + attributes: { 'webhook.url': 'https://hooks.example.com/orders', 'webhook.attempt': 1 }, + } + ) + ) + } + + return spans +} + +const FILLER_NAMES = [ + 'db.query', + 'cache.get', + 'http.request', + 'serialize', + 'validate', + 'deserialize', + 'queue.publish', + 'fn.invoke', + 'template.render', + 'policy.evaluate', +] + +const FILLER_SERVICES = [ + 'api-gateway', + 'auth-service', + 'postgres', + 'redis-cache', + 'payment-service', + 'fraud-service', + 'worker-service', + 'search-service', +] + +export function createSampleTrace(options: SampleTraceOptions = {}): SampleTrace { + const resolved: Required = { + spanCount: options.spanCount ?? 0, + seed: options.seed ?? 42, + startMs: options.startMs ?? SAMPLE_TRACE_START_MS, + includeOrphan: options.includeOrphan ?? true, + includeRunning: options.includeRunning ?? true, + includeZeroDuration: options.includeZeroDuration ?? true, + includeClockSkew: options.includeClockSkew ?? true, + } + + const base = resolved.startMs + const spans = baseSpans(base, resolved) + const nowMs = base + 430 + + if (resolved.spanCount > spans.length) { + const random = mulberry32(resolved.seed) + const depthOf = new Map() + const endOf = (candidate: Span) => candidate.endMs ?? nowMs + for (const existing of spans) depthOf.set(existing.id, 0) + + const parentPool = spans.filter((candidate) => endOf(candidate) - candidate.startMs > 2) + let counter = spans.length + 1 + + while (spans.length < resolved.spanCount) { + const parent = parentPool[Math.floor(random() * parentPool.length)] + const parentDepth = depthOf.get(parent.id) ?? 0 + const parentStart = parent.startMs + const parentDuration = Math.max(1, endOf(parent) - parentStart) + const start = parentStart + random() * parentDuration * 0.85 + const maxDuration = Math.max(0.05, endOf(parent) - start) + const duration = Math.max(0.05, random() * maxDuration * 0.6) + const isError = random() < 0.03 + const id = `span-${counter.toString(16).padStart(6, '0')}` + counter++ + + const child: Span = { + id, + parentId: parent.id, + traceId: SAMPLE_TRACE_ID, + name: FILLER_NAMES[Math.floor(random() * FILLER_NAMES.length)], + serviceName: FILLER_SERVICES[Math.floor(random() * FILLER_SERVICES.length)], + kind: 'internal', + startMs: start, + endMs: start + duration, + status: isError ? 'error' : 'ok', + attributes: { 'generated.index': counter, 'generated.depth': parentDepth + 1 }, + events: isError + ? [ + { + timeMs: start + duration * 0.9, + name: 'exception', + attributes: { 'exception.type': 'Error' }, + }, + ] + : [], + links: [], + } + spans.push(child) + depthOf.set(id, parentDepth + 1) + if (parentDepth + 1 < 12 && duration > 2) parentPool.push(child) + } + } + + return { traceId: SAMPLE_TRACE_ID, spans, nowMs } +} diff --git a/apps/design-system/registry/default/example/trace-span-bar-demo.tsx b/apps/design-system/registry/default/example/trace-span-bar-demo.tsx new file mode 100644 index 00000000000..f9d8880a617 --- /dev/null +++ b/apps/design-system/registry/default/example/trace-span-bar-demo.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { Slider, Toggle } from 'ui' +import { Trace, useSpan, type Span, type TimeWindow } from 'ui-patterns/Trace' + +import { createSampleTrace } from './trace-sample' + +const sample = createSampleTrace() +const root = sample.spans[0] +const base = root.startMs + +function make(id: string, name: string, overrides: Partial = {}): Span { + return { ...root, id, parentId: null, name, status: 'ok', events: [], ...overrides } +} + +const spans: Span[] = [ + make('default', 'default'), + make('selected', 'selected'), + make('error', 'error status', { status: 'error' }), + make('unset', 'unset status', { status: 'unset' }), + make('running', 'running', { startMs: base + 300, endMs: null, status: 'unset' }), + make('zero', 'zero duration', { startMs: base + 200, endMs: base + 200 }), + make('markers', 'markers', { + startMs: base + 20, + endMs: base + 380, + events: [ + { timeMs: base + 60, name: 'log', attributes: { 'log.severity': 'INFO' } }, + { timeMs: base + 200, name: 'cache.warmed' }, + { timeMs: base + 340, name: 'exception', attributes: { 'exception.type': 'TimeoutError' } }, + ], + }), +] + +const filteredSpans: Span[] = [ + make('dim', 'dim (ancestor of a match)'), + make('match', 'match', { parentId: 'dim', startMs: base + 100, endMs: base + 250 }), +] + +function Label() { + const { span } = useSpan() + return ( + + {span.name} + + ) +} + +function Gallery() { + return ( + + + + + ) +} + +export default function TraceSpanBarDemo() { + const [isZoomed, setIsZoomed] = useState(false) + const [offset, setOffset] = useState(120) + const window: TimeWindow | undefined = isZoomed ? [base + offset, base + offset + 180] : undefined + + return ( +
+
+ + Zoom window (clips bars) + + +
+ + + + + + + + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/trace-tree-cell-demo.tsx b/apps/design-system/registry/default/example/trace-tree-cell-demo.tsx new file mode 100644 index 00000000000..37abc7b7f15 --- /dev/null +++ b/apps/design-system/registry/default/example/trace-tree-cell-demo.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react' +import { Slider } from 'ui' +import { Trace } from 'ui-patterns/Trace' + +import { createSampleTrace } from './trace-sample' + +const sample = createSampleTrace() +const defaultCollapsed = new Set(['span-0008']) + +export default function TraceTreeCellDemo() { + const [indent, setIndent] = useState(14) + + return ( +
+ + + + + + + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/trace-waterfall-compact-demo.tsx b/apps/design-system/registry/default/example/trace-waterfall-compact-demo.tsx new file mode 100644 index 00000000000..252df2eb846 --- /dev/null +++ b/apps/design-system/registry/default/example/trace-waterfall-compact-demo.tsx @@ -0,0 +1,21 @@ +import { Trace } from 'ui-patterns/Trace' + +import { createSampleTrace } from './trace-sample' + +const sample = createSampleTrace({ includeOrphan: false, includeRunning: false }) + +export default function TraceWaterfallCompactDemo() { + return ( + + + + + + + + + + + + ) +} diff --git a/apps/design-system/registry/default/example/trace-waterfall-demo.tsx b/apps/design-system/registry/default/example/trace-waterfall-demo.tsx new file mode 100644 index 00000000000..ee7adc60730 --- /dev/null +++ b/apps/design-system/registry/default/example/trace-waterfall-demo.tsx @@ -0,0 +1,48 @@ +import { useMemo, useState } from 'react' +import { Toggle } from 'ui' +import { Trace } from 'ui-patterns/Trace' + +import { createSampleTrace } from './trace-sample' + +export default function TraceWaterfallDemo() { + const [isLarge, setIsLarge] = useState(false) + const sample = useMemo(() => createSampleTrace({ spanCount: isLarge ? 100_000 : 0 }), [isLarge]) + const [selectedId, setSelectedId] = useState('span-0008') + + return ( +
+ + + + + + + + + 100k spans + + + +
+ + + + + + + + + + + +
+
+
+ ) +} diff --git a/apps/design-system/registry/default/example/trace-waterfall-url-sync-demo.tsx b/apps/design-system/registry/default/example/trace-waterfall-url-sync-demo.tsx new file mode 100644 index 00000000000..c4db22f5440 --- /dev/null +++ b/apps/design-system/registry/default/example/trace-waterfall-url-sync-demo.tsx @@ -0,0 +1,54 @@ +import { useCallback, useSyncExternalStore } from 'react' +import { Trace } from 'ui-patterns/Trace' + +import { createSampleTrace } from './trace-sample' + +const sample = createSampleTrace() +const PARAM = 'span' + +function subscribe(callback: () => void) { + window.addEventListener('popstate', callback) + return () => window.removeEventListener('popstate', callback) +} + +function readSpanParam() { + return new URLSearchParams(window.location.search).get(PARAM) +} + +function useUrlSelection() { + const selectedId = useSyncExternalStore(subscribe, readSpanParam, () => null) + const setSelectedId = useCallback((id: string | null) => { + const url = new URL(window.location.href) + if (id) url.searchParams.set(PARAM, id) + else url.searchParams.delete(PARAM) + window.history.replaceState(window.history.state, '', url) + window.dispatchEvent(new PopStateEvent('popstate')) + }, []) + return { selectedId, setSelectedId } +} + +export default function TraceWaterfallUrlSyncDemo() { + const { selectedId, setSelectedId } = useUrlSelection() + + return ( +
+

+ ?{PARAM}={selectedId ?? '(none)'} +

+ +
+ + + + + + + + + + +
+
+
+ ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 0a47eeaa6c8..4f297ed94b2 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1924,4 +1924,50 @@ export const examples: Registry = [ type: 'components:example', files: ['example/status-code-demo.tsx'], }, + { + name: 'trace-ruler-demo', + type: 'components:example', + registryDependencies: ['slider'], + files: ['example/trace-ruler-demo.tsx'], + }, + { + name: 'trace-span-bar-demo', + type: 'components:example', + registryDependencies: ['slider', 'toggle'], + files: ['example/trace-span-bar-demo.tsx'], + }, + { + name: 'trace-tree-cell-demo', + type: 'components:example', + registryDependencies: ['slider', 'toggle'], + files: ['example/trace-tree-cell-demo.tsx'], + }, + { + name: 'trace-minimap-demo', + type: 'components:example', + registryDependencies: ['slider', 'toggle'], + files: ['example/trace-minimap-demo.tsx'], + }, + { + name: 'trace-inspector-demo', + type: 'components:example', + registryDependencies: ['toggle-group'], + files: ['example/trace-inspector-demo.tsx'], + }, + { + name: 'trace-waterfall-demo', + type: 'components:example', + registryDependencies: ['toggle'], + files: ['example/trace-waterfall-demo.tsx'], + }, + { + name: 'trace-waterfall-compact-demo', + type: 'components:example', + files: ['example/trace-waterfall-compact-demo.tsx'], + }, + { + name: 'trace-waterfall-url-sync-demo', + type: 'components:example', + files: ['example/trace-waterfall-url-sync-demo.tsx'], + }, ] diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 8cdc6963cdc..87df11e70d0 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -710,6 +710,66 @@ "import": "./src/Toc/types.ts", "types": "./src/Toc/types.ts" }, + "./Trace/Bar": { + "import": "./src/Trace/Bar.tsx", + "types": "./src/Trace/Bar.tsx" + }, + "./Trace/Cell": { + "import": "./src/Trace/Cell.tsx", + "types": "./src/Trace/Cell.tsx" + }, + "./Trace/Header": { + "import": "./src/Trace/Header.tsx", + "types": "./src/Trace/Header.tsx" + }, + "./Trace/Inspector": { + "import": "./src/Trace/Inspector.tsx", + "types": "./src/Trace/Inspector.tsx" + }, + "./Trace/Minimap": { + "import": "./src/Trace/Minimap.tsx", + "types": "./src/Trace/Minimap.tsx" + }, + "./Trace/Trace": { + "import": "./src/Trace/Trace.tsx", + "types": "./src/Trace/Trace.tsx" + }, + "./Trace/Waterfall": { + "import": "./src/Trace/Waterfall.tsx", + "types": "./src/Trace/Waterfall.tsx" + }, + "./Trace/hooks/useBrush": { + "import": "./src/Trace/hooks/useBrush.ts", + "types": "./src/Trace/hooks/useBrush.ts" + }, + "./Trace/hooks/useKeyboard": { + "import": "./src/Trace/hooks/useKeyboard.ts", + "types": "./src/Trace/hooks/useKeyboard.ts" + }, + "./Trace/hooks/useSpan": { + "import": "./src/Trace/hooks/useSpan.ts", + "types": "./src/Trace/hooks/useSpan.ts" + }, + "./Trace/hooks/useTimeScale": { + "import": "./src/Trace/hooks/useTimeScale.ts", + "types": "./src/Trace/hooks/useTimeScale.ts" + }, + "./Trace/hooks/useVisibleRows": { + "import": "./src/Trace/hooks/useVisibleRows.ts", + "types": "./src/Trace/hooks/useVisibleRows.ts" + }, + "./Trace": { + "import": "./src/Trace/index.ts", + "types": "./src/Trace/index.ts" + }, + "./Trace/types": { + "import": "./src/Trace/types.ts", + "types": "./src/Trace/types.ts" + }, + "./Trace/utils": { + "import": "./src/Trace/utils.ts", + "types": "./src/Trace/utils.ts" + }, "./TweetCard": { "import": "./src/TweetCard/index.tsx", "types": "./src/TweetCard/index.tsx" @@ -773,6 +833,7 @@ "@supabase/sql-to-rest": "^0.1.6", "@supabase/supabase-js": "catalog:", "@tanstack/react-table": "catalog:", + "@tanstack/react-virtual": "^3.13.24", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", "common": "workspace:*", diff --git a/packages/ui-patterns/src/Trace/Bar.tsx b/packages/ui-patterns/src/Trace/Bar.tsx new file mode 100644 index 00000000000..432667b4f6f --- /dev/null +++ b/packages/ui-patterns/src/Trace/Bar.tsx @@ -0,0 +1,142 @@ +'use client' + +import { CircleAlert } from 'lucide-react' +import type { ComponentProps } from 'react' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import { useSpan } from './hooks/useSpan' +import { useData, useView } from './Trace' +import type { MarkerKind, SpanEvent } from './types' +import { + computeBarGeometry, + durationOf, + effectiveEndMs, + formatMs, + inferMarkerKind, + MARKER_KIND_LABEL, +} from './utils' + +export function Bar({ className, style, children, ...props }: ComponentProps<'div'>) { + const { span, state, isRunning } = useSpan() + const { window } = useView() + const { nowMs } = useData() + + const endMs = effectiveEndMs(span, isRunning ? Math.max(nowMs, window[1]) : nowMs) + const geometry = computeBarGeometry(span.startMs, endMs, window) + if (!geometry.isVisible) return null + + return ( +
+ {children} +
+ ) +} + +interface MarkerProps { + event: SpanEvent + kind: MarkerKind + offsetMs: number +} + +function Marker({ event, kind, offsetMs }: MarkerProps) { + const { window, bounds } = useView() + const duration = window[1] - window[0] + if (!(duration > 0)) return null + const fraction = (event.timeMs - window[0]) / duration + if (fraction < 0 || fraction > 1) return null + + const kindLabel = MARKER_KIND_LABEL[kind] + + return ( + + + + + + {event.name} + · +{formatMs(offsetMs)} + + + ) +} + +export function Markers() { + const { span } = useSpan() + if (span.events.length === 0) return null + + return ( + <> + {span.events.map((event, index) => ( + + ))} + + ) +} + +export function Duration({ className, ...props }: ComponentProps<'span'>) { + const { span, isRunning } = useSpan() + const { nowMs } = useData() + const duration = formatMs(durationOf(span, nowMs)) + + return ( + + {isRunning ? `${duration}…` : duration} + + ) +} diff --git a/packages/ui-patterns/src/Trace/Cell.tsx b/packages/ui-patterns/src/Trace/Cell.tsx new file mode 100644 index 00000000000..1acbb470917 --- /dev/null +++ b/packages/ui-patterns/src/Trace/Cell.tsx @@ -0,0 +1,119 @@ +'use client' + +import { ChevronRight, Unplug } from 'lucide-react' +import type { ComponentProps } from 'react' +import { Badge, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import { Duration } from './Bar' +import { useSpan } from './hooks/useSpan' +import { useView } from './Trace' + +export interface CellProps extends ComponentProps<'div'> { + indent?: number +} + +const TOGGLE_SIZE = 18 + +export function Cell({ indent = 14, className, style, ...props }: CellProps) { + const { row, span, readOnly } = useSpan() + const { dispatch } = useView() + const guideOffset = TOGGLE_SIZE / 2 + 4 + + return ( +
+ {row.guides.map((show, depth) => + show ? ( + + ) : null + )} + + + {row.hasChildren && ( +
+ ) +} diff --git a/packages/ui-patterns/src/Trace/Header.tsx b/packages/ui-patterns/src/Trace/Header.tsx new file mode 100644 index 00000000000..98aa4a2e521 --- /dev/null +++ b/packages/ui-patterns/src/Trace/Header.tsx @@ -0,0 +1,145 @@ +'use client' + +import { ChevronsDownUp, Maximize2, Search } from 'lucide-react' +import type { ComponentProps } from 'react' +import type { BadgeProps, ButtonProps } from 'ui' +import { Badge, Button, cn, InputGroup, InputGroupAddon, InputGroupInput } from 'ui' + +import { useData, useView } from './Trace' +import type { SpanStatus } from './types' + +export function Header({ className, children, ...props }: ComponentProps<'div'>) { + return ( +
+ {children} +
+ ) +} + +export function Title({ className, children, ...props }: ComponentProps<'h2'>) { + const { roots, byId } = useData() + const root = roots.length > 0 ? byId.get(roots[0]) : undefined + return ( +

+ {children ?? root?.name ?? 'Trace'} +

+ ) +} + +export const STATUS_BADGE_VARIANT: Record = { + ok: 'success', + error: 'destructive', + unset: 'default', +} + +export function Status({ className, children, ...props }: Omit) { + const { roots, byId } = useData() + const root = roots.length > 0 ? byId.get(roots[0]) : undefined + const status: SpanStatus = root?.status ?? 'unset' + return ( + + {children ?? status} + + ) +} + +export interface FilterProps extends Omit, 'value' | 'onChange' | 'size'> { + className?: string +} + +export function Filter({ + className, + placeholder = 'Filter spans', + onKeyDown, + ...props +}: FilterProps) { + const { query, matchIds, dispatch } = useView() + + return ( + + + + + dispatch({ type: 'setQuery', query: event.target.value })} + onKeyDown={(event) => { + onKeyDown?.(event) + if (event.key === 'Escape' && query !== '' && !event.defaultPrevented) { + event.preventDefault() + dispatch({ type: 'setQuery', query: '' }) + } + }} + {...props} + /> + {matchIds && ( + + + {matchIds.size} + + + )} + + ) +} + +export function CollapseAll({ children, onClick, ...props }: ButtonProps) { + const { dispatch } = useView() + return ( + + ) +} + +export function ResetZoom({ children, onClick, ...props }: ButtonProps) { + const { isReset, dispatch } = useView() + return ( + + ) +} diff --git a/packages/ui-patterns/src/Trace/Inspector.tsx b/packages/ui-patterns/src/Trace/Inspector.tsx new file mode 100644 index 00000000000..ea72f8b79df --- /dev/null +++ b/packages/ui-patterns/src/Trace/Inspector.tsx @@ -0,0 +1,379 @@ +'use client' + +import { ChevronRight, CircleAlert, Copy, CornerDownRight, ExternalLink } from 'lucide-react' +import type { ComponentProps, ReactNode } from 'react' +import { toast } from 'sonner' +import { + Badge, + Button, + cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + copyToClipboard, + ScrollArea, + Table, + TableBody, + TableCell, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' + +import { STATUS_BADGE_VARIANT } from './Header' +import { useData, useView } from './Trace' +import type { Span, ValueKind } from './types' +import { + detectValueKind, + durationOf, + formatMs, + inferMarkerKind, + sortedEntries, + stringifyValue, + summarizeJson, +} from './utils' + +function copy(text: string) { + copyToClipboard(text, () => toast.success('Copied to clipboard')) +} + +interface ValueProps { + entryKey: string + value: unknown + kind: ValueKind +} + +function Value({ entryKey, value, kind }: ValueProps) { + const { byId } = useData() + const { dispatch } = useView() + + switch (kind) { + case 'null': + return null + case 'boolean': + return ( + {String(value)} + ) + case 'number': + return {stringifyValue(value)} + case 'id': { + const id = String(value) + if (!byId.has(id) || /trace/i.test(entryKey)) return {id} + return ( + + ) + } + case 'url': { + const href = String(value) + return ( + + {href} + + + ) + } + case 'error': + return ( +
+          {String(value)}
+        
+ ) + case 'json': + return ( + + + + + +
+              {stringifyValue(value)}
+            
+
+
+ ) + default: + return {String(value)} + } +} + +interface KeyValueProps { + data: Record + emptyMessage?: ReactNode + className?: string +} + +function KeyValue({ data, emptyMessage = 'No attributes', className }: KeyValueProps) { + const entries = sortedEntries(data) + + if (entries.length === 0) { + return ( +
+ {emptyMessage} +
+ ) + } + + return ( +
+ + + {entries.map(([key, value]) => { + const kind = detectValueKind(key, value) + return ( + + + {key} + + +
+
+ +
+ + +
+
+
+ ) + })} +
+
+
+ ) +} + +interface SectionProps { + title: ReactNode + count?: number + children: ReactNode +} + +function Section({ title, count, children }: SectionProps) { + return ( +
+

+ {title} + {count !== undefined && ( + {count} + )} +

+
{children}
+
+ ) +} + +function SpanHeader({ span }: { span: Span }) { + const { nowMs, bounds } = useData() + const isRunning = span.endMs === null + const duration = formatMs(durationOf(span, nowMs)) + + return ( +
+
+ + {span.serviceName} + + + {span.status} + + {span.kind} + {isRunning && running} +
+ + +

{span.name}

+
+ + {span.name} + +
+
+
Duration
+
{isRunning ? `${duration}…` : duration}
+
Started
+
+{formatMs(span.startMs - bounds[0])}
+
Span ID
+
+ {span.id} +
+
+
+ ) +} + +function Events({ span }: { span: Span }) { + return ( +
+ {span.events.length === 0 && ( +

No events

+ )} + {span.events.length > 0 && ( +
    + {span.events.map((event, index) => { + const kind = inferMarkerKind(event) + const hasAttributes = event.attributes && Object.keys(event.attributes).length > 0 + return ( +
  1. +
    + + +{formatMs(event.timeMs - span.startMs)} + + {kind === 'exception' && ( + + )} + + {event.name} + +
    + {hasAttributes && } +
  2. + ) + })} +
+ )} +
+ ) +} + +function Links({ span }: { span: Span }) { + const { byId } = useData() + const { dispatch } = useView() + if (span.links.length === 0) return null + + return ( +
+
    + {span.links.map((link) => ( +
  • + trace + {link.traceId} + span + {byId.has(link.spanId) ? ( + + ) : ( + {link.spanId} + )} +
  • + ))} +
+
+ ) +} + +export interface InspectorProps extends ComponentProps<'aside'> { + spanId?: string + emptyMessage?: ReactNode +} + +export function Inspector({ spanId, emptyMessage, className, ...props }: InspectorProps) { + const data = useData() + const { selectedId } = useView() + const resolvedId = spanId ?? selectedId + const span = resolvedId !== null ? (data.byId.get(resolvedId) ?? null) : null + + return ( + + ) +} diff --git a/packages/ui-patterns/src/Trace/Minimap.tsx b/packages/ui-patterns/src/Trace/Minimap.tsx new file mode 100644 index 00000000000..035362921f2 --- /dev/null +++ b/packages/ui-patterns/src/Trace/Minimap.tsx @@ -0,0 +1,226 @@ +'use client' + +import type { ComponentProps } from 'react' +import { useMemo } from 'react' +import { cn } from 'ui' + +import { useBrush } from './hooks/useBrush' +import { useTimeScale } from './hooks/useTimeScale' +import { useData, useView } from './Trace' +import type { SpanStatus, TimeWindow } from './types' +import { effectiveEndMs, formatMs, msToFraction } from './utils' + +export interface MinimapProps extends ComponentProps<'div'> { + height?: number + maxDetailSpans?: number + laneHeight?: number +} + +const VIEWBOX_WIDTH = 1000 + +interface DensityBucket { + x: number + total: number + errors: number +} + +interface MinimapItem { + id: string + startMs: number + endMs: number + status: SpanStatus + depth: number +} + +function computeDensity( + items: readonly MinimapItem[], + bounds: TimeWindow, + bucketCount: number +): DensityBucket[] { + const duration = bounds[1] - bounds[0] + if (!(duration > 0) || bucketCount <= 0) return [] + const total = new Array(bucketCount + 1).fill(0) + const errors = new Array(bucketCount + 1).fill(0) + for (const item of items) { + const from = Math.max(0, Math.floor(((item.startMs - bounds[0]) / duration) * bucketCount)) + const to = Math.min( + bucketCount - 1, + Math.max(from, Math.ceil(((item.endMs - bounds[0]) / duration) * bucketCount) - 1) + ) + total[from] += 1 + total[to + 1] -= 1 + if (item.status === 'error') { + errors[from] += 1 + errors[to + 1] -= 1 + } + } + const buckets: DensityBucket[] = [] + let runningTotal = 0 + let runningErrors = 0 + for (let i = 0; i < bucketCount; i++) { + runningTotal += total[i] + runningErrors += errors[i] + buckets.push({ x: i / bucketCount, total: runningTotal, errors: runningErrors }) + } + return buckets +} + +const STATUS_FILL: Record = { + ok: 'fill-brand', + error: 'fill-destructive', + unset: 'fill-foreground-muted', +} + +export function Minimap({ + height = 48, + maxDetailSpans = 2000, + laneHeight = 3, + className, + ...props +}: MinimapProps) { + const index = useData() + const { window, bounds, dispatch } = useView() + const { ref, width } = useTimeScale() + const brush = useBrush({ + bounds, + window, + width, + onWindowChange: (next) => dispatch({ type: 'setWindow', window: next }), + onReset: () => dispatch({ type: 'setWindow', window: null }), + }) + + const isDetail = index.byId.size <= maxDetailSpans + const maxLanes = Math.max(1, Math.floor(height / laneHeight)) + + const shapes = useMemo(() => { + const items: MinimapItem[] = Array.from(index.byId.values()).map((span) => ({ + id: span.id, + startMs: span.startMs, + endMs: effectiveEndMs(span, index.nowMs), + status: span.status, + depth: index.depthOf.get(span.id) ?? 0, + })) + if (isDetail) return { kind: 'detail' as const, items } + const bucketCount = Math.max(50, Math.min(400, Math.floor(width / 2) || 200)) + return { kind: 'density' as const, buckets: computeDensity(items, bounds, bucketCount) } + }, [index, isDetail, bounds, width]) + + const maxDensity = + shapes.kind === 'density' ? Math.max(1, ...shapes.buckets.map((bucket) => bucket.total)) : 1 + + const leftShade = msToFraction(window[0], bounds) + const rightShade = 1 - msToFraction(window[1], bounds) + + return ( +
+ + {shapes.kind === 'detail' && + shapes.items.map((item) => { + const x = msToFraction(item.startMs, bounds) * VIEWBOX_WIDTH + const w = Math.max(1, msToFraction(item.endMs, bounds) * VIEWBOX_WIDTH - x) + const lane = Math.min(item.depth, maxLanes - 1) + return ( + + ) + })} + {shapes.kind === 'density' && + shapes.buckets.map((bucket) => { + const barHeight = (bucket.total / maxDensity) * height + const errorHeight = (bucket.errors / maxDensity) * height + const barWidth = VIEWBOX_WIDTH / shapes.buckets.length + return ( + + + {errorHeight > 0 && ( + + )} + + ) + })} + + +
+
+
+
+
+ +
+
+ +
+
+
+
+ ) +} diff --git a/packages/ui-patterns/src/Trace/Trace.tsx b/packages/ui-patterns/src/Trace/Trace.tsx new file mode 100644 index 00000000000..4837f4b5331 --- /dev/null +++ b/packages/ui-patterns/src/Trace/Trace.tsx @@ -0,0 +1,172 @@ +'use client' + +import { createContext, use, useCallback, useMemo, useReducer, useRef } from 'react' + +import type { + DataContextValue, + RootProps, + TimeWindow, + ViewAction, + ViewContextValue, + ViewState, +} from './types' +import { buildTraceIndex, clampWindow, computeMatchIds, isFullWindow } from './utils' + +export const DataContext = createContext(null) +export const ViewContext = createContext(null) + +const EMPTY_SET: ReadonlySet = new Set() + +function viewReducer(state: ViewState, action: ViewAction): ViewState { + switch (action.type) { + case 'setWindow': + return state.window === action.window ? state : { ...state, window: action.window } + case 'select': + return state.selectedId === action.id ? state : { ...state, selectedId: action.id } + case 'hover': + return state.hoveredId === action.id ? state : { ...state, hoveredId: action.id } + case 'toggle': { + const collapsed = new Set(state.collapsed) + if (collapsed.has(action.id)) collapsed.delete(action.id) + else collapsed.add(action.id) + return { ...state, collapsed } + } + case 'expand': { + if (!state.collapsed.has(action.id)) return state + const collapsed = new Set(state.collapsed) + collapsed.delete(action.id) + return { ...state, collapsed } + } + case 'collapse': { + if (state.collapsed.has(action.id)) return state + const collapsed = new Set(state.collapsed) + collapsed.add(action.id) + return { ...state, collapsed } + } + case 'expandAll': + return state.collapsed.size === 0 ? state : { ...state, collapsed: EMPTY_SET } + case 'setCollapsed': + return { ...state, collapsed: action.collapsed } + case 'setQuery': + return state.query === action.query ? state : { ...state, query: action.query } + case 'collapseAll': + return state + default: + return state + } +} + +export function Root({ + trace, + selectedId: selectedIdProp, + onSelectedIdChange, + window: windowProp, + onWindowChange, + defaultCollapsed = EMPTY_SET, + defaultQuery = '', + children, +}: RootProps) { + const index = useMemo(() => buildTraceIndex(trace.spans, trace.nowMs), [trace.spans, trace.nowMs]) + + const [state, rawDispatch] = useReducer( + viewReducer, + null, + (): ViewState => ({ + window: null, + selectedId: null, + hoveredId: null, + collapsed: defaultCollapsed, + query: defaultQuery, + }) + ) + + const bounds = index.bounds + const isWindowControlled = windowProp !== undefined + const isSelectionControlled = selectedIdProp !== undefined + + const latest = useRef({ + index, + onSelectedIdChange, + onWindowChange, + isWindowControlled, + isSelectionControlled, + }) + latest.current = { + index, + onSelectedIdChange, + onWindowChange, + isWindowControlled, + isSelectionControlled, + } + + const dispatch = useCallback((action: ViewAction) => { + const current = latest.current + switch (action.type) { + case 'select': + current.onSelectedIdChange?.(action.id) + if (!current.isSelectionControlled) rawDispatch(action) + return + case 'setWindow': { + const b = current.index.bounds + const next: TimeWindow = action.window === null ? b : clampWindow(action.window, b) + current.onWindowChange?.(next) + if (!current.isWindowControlled) { + rawDispatch({ type: 'setWindow', window: isFullWindow(next, b) ? null : next }) + } + return + } + case 'collapseAll': { + const parents = new Set() + for (const [id, children] of current.index.childrenOf) { + if (children.length > 0) parents.add(id) + } + rawDispatch({ type: 'setCollapsed', collapsed: parents }) + return + } + default: + rawDispatch(action) + } + }, []) + + const window = useMemo(() => { + const stored = isWindowControlled ? windowProp : state.window + if (!stored) return bounds + return clampWindow(stored, bounds) + }, [isWindowControlled, windowProp, state.window, bounds]) + + const selectedId = isSelectionControlled ? (selectedIdProp ?? null) : state.selectedId + const matchIds = useMemo(() => computeMatchIds(index, state.query), [index, state.query]) + + const view = useMemo( + () => ({ + window, + bounds, + isReset: isFullWindow(window, bounds), + selectedId, + hoveredId: state.hoveredId, + collapsed: state.collapsed, + query: state.query, + matchIds, + dispatch, + }), + [window, bounds, selectedId, state.hoveredId, state.collapsed, state.query, matchIds, dispatch] + ) + + return ( + + {children} + + ) +} + +export function useData(): DataContextValue { + const value = use(DataContext) + if (!value) throw new Error('Trace parts must be rendered inside Trace.Root') + return value +} + +export function useView(): ViewContextValue { + const value = use(ViewContext) + if (!value) throw new Error('Trace parts must be rendered inside Trace.Root') + return value +} diff --git a/packages/ui-patterns/src/Trace/Waterfall.tsx b/packages/ui-patterns/src/Trace/Waterfall.tsx new file mode 100644 index 00000000000..82dc7102240 --- /dev/null +++ b/packages/ui-patterns/src/Trace/Waterfall.tsx @@ -0,0 +1,294 @@ +'use client' + +import { useVirtualizer } from '@tanstack/react-virtual' +import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui' +import type { ComponentProps, CSSProperties, KeyboardEvent, ReactNode } from 'react' +import { createContext, use, useCallback, useEffect, useMemo, useRef } from 'react' +import { cn, ScrollBar, ScrollViewport } from 'ui' + +import { useKeyboard } from './hooks/useKeyboard' +import { SpanContext } from './hooks/useSpan' +import { useTimeScale } from './hooks/useTimeScale' +import { useVisibleRows } from './hooks/useVisibleRows' +import { useView } from './Trace' +import type { BarState, SpanContextValue, VisibleRow } from './types' + +export interface WaterfallProps extends ComponentProps<'div'> { + treeWidth?: number +} + +export function Waterfall({ + treeWidth = 320, + className, + style, + children, + ...props +}: WaterfallProps) { + const vars = { '--trace-tree-width': `${treeWidth}px` } as CSSProperties + return ( +
+ {children} +
+ ) +} + +export interface RulerProps extends ComponentProps<'div'> { + targetTickSpacing?: number +} + +export function Ruler({ targetTickSpacing = 80, className, children, ...props }: RulerProps) { + const { ref, scale } = useTimeScale({ targetTickSpacing }) + + return ( +
+
+ {children ?? 'Span'} +
+
+ {scale.ticks.map((tick) => ( +
+ + {tick.label} + +
+ ))} +
+
+ ) +} + +interface RowsContextValue { + readOnly: boolean + focusableId: string | null +} + +const RowsContext = createContext({ readOnly: false, focusableId: null }) + +interface RowProps { + row: VisibleRow + children: ReactNode +} + +function Row({ row, children }: RowProps) { + const { selectedId, hoveredId, dispatch } = useView() + const { readOnly, focusableId } = use(RowsContext) + + const { span } = row + const isSelected = !readOnly && row.id === selectedId + const isHovered = !readOnly && row.id === hoveredId + const isRunning = span.endMs === null + const isDim = row.filterMatch === 'ancestor' + + let state: BarState = 'default' + if (isSelected) state = 'selected' + else if (isHovered) state = 'hover' + else if (isDim) state = 'dim' + else if (isRunning) state = 'running' + + const value = useMemo( + () => ({ span, row, isSelected, isHovered, isRunning, state, readOnly }), + [span, row, isSelected, isHovered, isRunning, state, readOnly] + ) + + return ( + +
{ + if (!readOnly) dispatch({ type: 'select', id: row.id }) + }} + onPointerEnter={() => { + if (!readOnly) dispatch({ type: 'hover', id: row.id }) + }} + onPointerLeave={() => { + if (!readOnly) dispatch({ type: 'hover', id: null }) + }} + > + {children} +
+
+ ) +} + +export interface RowsProps extends ComponentProps { + rowHeight?: number + overscan?: number + readOnly?: boolean + emptyMessage?: ReactNode +} + +export function Rows({ + rowHeight = 28, + overscan = 8, + readOnly = false, + emptyMessage, + children, + className, + 'aria-label': ariaLabel = 'Spans', + ...props +}: RowsProps) { + const { rows, rowIndexById, totalCount, matchCount } = useVisibleRows() + const { selectedId, dispatch } = useView() + + const viewportRef = useRef(null) + const treeRef = useRef(null) + const pendingFocus = useRef(false) + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => viewportRef.current, + estimateSize: () => rowHeight, + overscan, + getItemKey: (index) => rows[index].id, + }) + + const keyboard = useKeyboard({ rows, rowIndexById, selectedId, dispatch, disabled: readOnly }) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + pendingFocus.current = true + keyboard.onKeyDown(event) + }, + [keyboard] + ) + + useEffect(() => { + if (readOnly || selectedId === null) return + const index = rowIndexById.get(selectedId) + if (index === undefined) return + virtualizer.scrollToIndex(index, { align: 'auto' }) + }, [selectedId, rowIndexById, virtualizer, readOnly]) + + useEffect(() => { + if (!pendingFocus.current || selectedId === null) return + pendingFocus.current = false + let frame = 0 + const focusRow = (attempt: number) => { + const tree = treeRef.current + if (!tree) return + const target = tree.querySelector( + `[data-trace-row-id="${CSS.escape(selectedId)}"]` + ) + if (target) { + target.focus({ preventScroll: true }) + return + } + if (attempt < 3) frame = requestAnimationFrame(() => focusRow(attempt + 1)) + } + focusRow(0) + return () => cancelAnimationFrame(frame) + }, [selectedId]) + + const rowsContext = useMemo( + () => ({ readOnly, focusableId: readOnly ? null : (selectedId ?? rows[0]?.id ?? null) }), + [readOnly, selectedId, rows] + ) + + const vars = { '--trace-row-height': `${rowHeight}px` } as CSSProperties + + let fallback = emptyMessage ?? 'No spans in this trace' + if (emptyMessage === undefined && totalCount > 0 && matchCount === 0) { + fallback = 'No spans match this filter' + } + + return ( + + + + {rows.length === 0 && ( +
+ {fallback} +
+ )} +
+ {virtualizer.getVirtualItems().map((item) => { + const row = rows[item.index] + if (!row) return null + return ( +
+ {children} +
+ ) + })} +
+
+
+ + +
+ ) +} + +export function Lane({ className, children, ...props }: ComponentProps<'div'>) { + return ( +
+ {children} +
+ ) +} diff --git a/packages/ui-patterns/src/Trace/hooks/useBrush.ts b/packages/ui-patterns/src/Trace/hooks/useBrush.ts new file mode 100644 index 00000000000..0657e671c33 --- /dev/null +++ b/packages/ui-patterns/src/Trace/hooks/useBrush.ts @@ -0,0 +1,250 @@ +import type { KeyboardEvent, PointerEvent } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' + +import type { BrushEdge, BrushMode, BrushRect, TimeWindow } from '../types' +import { brushRect, isFullWindow, panWindow, pxToMs, resizeWindow, windowFromDrag } from '../utils' + +export interface UseBrushOptions { + bounds: TimeWindow + window: TimeWindow + width: number + onWindowChange: (window: TimeWindow) => void + onReset?: () => void + minDurationMs?: number + clickThresholdPx?: number + keyboardStepFraction?: number +} + +interface DragState { + mode: BrushMode + pointerId: number + originX: number + trackLeft: number + originWindow: TimeWindow + moved: boolean +} + +export interface BrushTrackProps { + onPointerDown: (event: PointerEvent) => void + onPointerMove: (event: PointerEvent) => void + onPointerUp: (event: PointerEvent) => void + onPointerCancel: (event: PointerEvent) => void + onDoubleClick: () => void +} + +export interface BrushBodyProps extends Omit { + onKeyDown: (event: KeyboardEvent) => void + tabIndex: number +} + +export interface BrushHandleProps extends Omit { + onKeyDown: (event: KeyboardEvent) => void + role: 'slider' + tabIndex: number + 'aria-valuemin': number + 'aria-valuemax': number + 'aria-valuenow': number + 'aria-orientation': 'horizontal' +} + +export interface UseBrushResult { + rect: BrushRect + mode: BrushMode + isDragging: boolean + isFull: boolean + getTrackProps: () => BrushTrackProps + getBrushProps: () => BrushBodyProps + getHandleProps: (edge: BrushEdge) => BrushHandleProps +} + +export function useBrush(options: UseBrushOptions): UseBrushResult { + const { + bounds, + window, + width, + onWindowChange, + onReset, + minDurationMs, + clickThresholdPx = 3, + keyboardStepFraction = 0.05, + } = options + + const [mode, setMode] = useState('idle') + const drag = useRef(null) + + const latest = useRef({ bounds, window, width, onWindowChange, onReset, minDurationMs }) + latest.current = { bounds, window, width, onWindowChange, onReset, minDurationMs } + + const reset = useCallback(() => { + const { onReset: handleReset, onWindowChange: change, bounds: b } = latest.current + if (handleReset) handleReset() + else change([b[0], b[1]]) + }, []) + + const begin = useCallback((event: PointerEvent, nextMode: BrushMode) => { + if (event.button !== 0) return + const track = event.currentTarget.closest('[data-trace-brush-track]') + const trackLeft = (track ?? event.currentTarget).getBoundingClientRect().left + event.currentTarget.setPointerCapture(event.pointerId) + drag.current = { + mode: nextMode, + pointerId: event.pointerId, + originX: event.clientX - trackLeft, + trackLeft, + originWindow: latest.current.window, + moved: false, + } + setMode(nextMode) + }, []) + + const move = useCallback((event: PointerEvent) => { + const state = drag.current + if (!state || state.pointerId !== event.pointerId) return + const { bounds: b, width: w, onWindowChange: change, minDurationMs: min } = latest.current + const x = event.clientX - state.trackLeft + const dx = x - state.originX + if (Math.abs(dx) >= 1) state.moved = true + if (!state.moved) return + + switch (state.mode) { + case 'create': + change(windowFromDrag(pxToMs(state.originX, b, w), pxToMs(x, b, w), b, min)) + break + case 'move': { + const deltaMs = pxToMs(dx, b, w) - b[0] + change(panWindow(state.originWindow, deltaMs, b)) + break + } + case 'resize-start': + change(resizeWindow(state.originWindow, 'start', pxToMs(x, b, w), b, min)) + break + case 'resize-end': + change(resizeWindow(state.originWindow, 'end', pxToMs(x, b, w), b, min)) + break + default: + break + } + }, []) + + const end = useCallback( + (event: PointerEvent) => { + const state = drag.current + if (!state || state.pointerId !== event.pointerId) return + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + const { bounds: b, width: w, window: current, onWindowChange: change } = latest.current + const x = event.clientX - state.trackLeft + const isClick = Math.abs(x - state.originX) < clickThresholdPx + if (state.mode === 'create' && isClick && !isFullWindow(current, b)) { + const size = current[1] - current[0] + change(panWindow(current, pxToMs(x, b, w) - size / 2 - current[0], b)) + } + drag.current = null + setMode('idle') + }, + [clickThresholdPx] + ) + + const sharedPointerProps = useMemo( + () => ({ onPointerMove: move, onPointerUp: end, onPointerCancel: end }), + [move, end] + ) + + const getTrackProps = useCallback( + (): BrushTrackProps => ({ + onPointerDown: (event) => { + if (event.target !== event.currentTarget) return + begin(event, 'create') + }, + ...sharedPointerProps, + onDoubleClick: reset, + }), + [begin, sharedPointerProps, reset] + ) + + const keyboardPan = useCallback( + (direction: -1 | 1) => { + const { bounds: b, window: current, onWindowChange: change } = latest.current + const step = (current[1] - current[0]) * keyboardStepFraction * direction + change(panWindow(current, step, b)) + }, + [keyboardStepFraction] + ) + + const keyboardResize = useCallback( + (edge: BrushEdge, direction: -1 | 1) => { + const { + bounds: b, + window: current, + onWindowChange: change, + minDurationMs: min, + } = latest.current + const step = (b[1] - b[0]) * keyboardStepFraction * direction + const ms = (edge === 'start' ? current[0] : current[1]) + step + change(resizeWindow(current, edge, ms, b, min)) + }, + [keyboardStepFraction] + ) + + const getBrushProps = useCallback( + (): BrushBodyProps => ({ + onPointerDown: (event) => { + if (event.target !== event.currentTarget) return + event.stopPropagation() + begin(event, 'move') + }, + ...sharedPointerProps, + onKeyDown: (event) => { + if (event.key === 'ArrowLeft') { + event.preventDefault() + keyboardPan(-1) + } else if (event.key === 'ArrowRight') { + event.preventDefault() + keyboardPan(1) + } else if (event.key === 'Escape' || event.key === 'Home') { + event.preventDefault() + reset() + } + }, + tabIndex: 0, + }), + [begin, sharedPointerProps, keyboardPan, reset] + ) + + const getHandleProps = useCallback( + (edge: BrushEdge): BrushHandleProps => ({ + onPointerDown: (event) => { + event.stopPropagation() + begin(event, edge === 'start' ? 'resize-start' : 'resize-end') + }, + ...sharedPointerProps, + onKeyDown: (event) => { + if (event.key === 'ArrowLeft') { + event.preventDefault() + keyboardResize(edge, -1) + } else if (event.key === 'ArrowRight') { + event.preventDefault() + keyboardResize(edge, 1) + } + }, + role: 'slider', + tabIndex: 0, + 'aria-valuemin': bounds[0], + 'aria-valuemax': bounds[1], + 'aria-valuenow': edge === 'start' ? window[0] : window[1], + 'aria-orientation': 'horizontal', + }), + [begin, sharedPointerProps, keyboardResize, bounds, window] + ) + + return { + rect: brushRect(window, bounds, width), + mode, + isDragging: mode !== 'idle', + isFull: isFullWindow(window, bounds), + getTrackProps, + getBrushProps, + getHandleProps, + } +} diff --git a/packages/ui-patterns/src/Trace/hooks/useKeyboard.ts b/packages/ui-patterns/src/Trace/hooks/useKeyboard.ts new file mode 100644 index 00000000000..994731ac897 --- /dev/null +++ b/packages/ui-patterns/src/Trace/hooks/useKeyboard.ts @@ -0,0 +1,44 @@ +import type { Dispatch, KeyboardEvent } from 'react' +import { useCallback } from 'react' + +import type { KeyboardAction, ViewAction, VisibleRow } from '../types' +import { NAVIGATION_KEYS, resolveKeyboardAction } from '../utils' + +export interface UseKeyboardOptions { + rows: readonly VisibleRow[] + rowIndexById: ReadonlyMap + selectedId: string | null + dispatch: Dispatch + disabled?: boolean +} + +export interface UseKeyboardResult { + onKeyDown: (event: KeyboardEvent) => void + press: (key: string) => KeyboardAction | null +} + +export function useKeyboard(options: UseKeyboardOptions): UseKeyboardResult { + const { rows, rowIndexById, selectedId, dispatch, disabled = false } = options + + const press = useCallback( + (key: string): KeyboardAction | null => { + if (disabled) return null + const action = resolveKeyboardAction({ key, rows, selectedId, rowIndexById }) + if (action) dispatch(action) + return action + }, + [disabled, rows, selectedId, rowIndexById, dispatch] + ) + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (disabled || !NAVIGATION_KEYS.has(event.key)) return + if (event.altKey || event.ctrlKey || event.metaKey) return + event.preventDefault() + press(event.key) + }, + [disabled, press] + ) + + return { onKeyDown, press } +} diff --git a/packages/ui-patterns/src/Trace/hooks/useSpan.ts b/packages/ui-patterns/src/Trace/hooks/useSpan.ts new file mode 100644 index 00000000000..a12277ef327 --- /dev/null +++ b/packages/ui-patterns/src/Trace/hooks/useSpan.ts @@ -0,0 +1,11 @@ +import { createContext, use } from 'react' + +import type { SpanContextValue } from '../types' + +export const SpanContext = createContext(null) + +export function useSpan(): SpanContextValue { + const value = use(SpanContext) + if (!value) throw new Error('This part must be rendered inside the Trace.Rows template') + return value +} diff --git a/packages/ui-patterns/src/Trace/hooks/useTimeScale.ts b/packages/ui-patterns/src/Trace/hooks/useTimeScale.ts new file mode 100644 index 00000000000..1346a28aec5 --- /dev/null +++ b/packages/ui-patterns/src/Trace/hooks/useTimeScale.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { useView } from '../Trace' +import type { TimeScale } from '../types' +import { computeTimeScale } from '../utils' + +export interface UseTimeScaleOptions { + targetTickSpacing?: number +} + +export interface UseTimeScaleResult { + ref: (element: T | null) => void + width: number + scale: TimeScale +} + +export function useTimeScale( + options: UseTimeScaleOptions = {} +): UseTimeScaleResult { + const { targetTickSpacing = 80 } = options + const { window, bounds } = useView() + const [element, setElement] = useState(null) + const [width, setWidth] = useState(0) + + const ref = useCallback((node: T | null) => setElement(node), []) + + useEffect(() => { + if (!element) return + const measure = () => { + const next = element.getBoundingClientRect().width + setWidth((previous) => (Math.abs(previous - next) < 0.5 ? previous : next)) + } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(element) + return () => observer.disconnect() + }, [element]) + + const [start, end] = window + const origin = bounds[0] + const scale = useMemo( + () => computeTimeScale({ window: [start, end], width, origin, targetTickSpacing }), + [start, end, width, origin, targetTickSpacing] + ) + + return { ref, width, scale } +} diff --git a/packages/ui-patterns/src/Trace/hooks/useVisibleRows.ts b/packages/ui-patterns/src/Trace/hooks/useVisibleRows.ts new file mode 100644 index 00000000000..27bb51757ff --- /dev/null +++ b/packages/ui-patterns/src/Trace/hooks/useVisibleRows.ts @@ -0,0 +1,27 @@ +import { useMemo } from 'react' + +import { useData, useView } from '../Trace' +import type { VisibleRow } from '../types' +import { flattenTree, indexRowsById } from '../utils' + +export interface UseVisibleRowsResult { + rows: readonly VisibleRow[] + rowIndexById: ReadonlyMap + totalCount: number + matchCount: number | null +} + +export function useVisibleRows(): UseVisibleRowsResult { + const index = useData() + const { collapsed, matchIds } = useView() + + return useMemo(() => { + const rows = flattenTree(index, collapsed, matchIds) + return { + rows, + rowIndexById: indexRowsById(rows), + totalCount: index.byId.size, + matchCount: matchIds ? matchIds.size : null, + } + }, [index, collapsed, matchIds]) +} diff --git a/packages/ui-patterns/src/Trace/index.ts b/packages/ui-patterns/src/Trace/index.ts new file mode 100644 index 00000000000..ebd95365459 --- /dev/null +++ b/packages/ui-patterns/src/Trace/index.ts @@ -0,0 +1,49 @@ +import { Bar, Duration, Markers } from './Bar' +import { Cell } from './Cell' +import { CollapseAll, Filter, Header, ResetZoom, Status, Title } from './Header' +import { Inspector } from './Inspector' +import { Minimap } from './Minimap' +import { Root } from './Trace' +import { Lane, Rows, Ruler, Waterfall } from './Waterfall' + +export const Trace = { + Root, + Header, + Title, + Status, + Filter, + CollapseAll, + ResetZoom, + Minimap, + Waterfall, + Ruler, + Rows, + Cell, + Lane, + Bar, + Markers, + Duration, + Inspector, +} + +export { useSpan } from './hooks/useSpan' +export { useTimeScale } from './hooks/useTimeScale' + +export type { + BarState, + MarkerKind, + RootProps, + Span, + SpanContextValue, + SpanEvent, + SpanKind, + SpanLink, + SpanStatus, + TimeScale, + TimeTick, + TimeWindow, + TraceData, + ViewAction, + ViewContextValue, + VisibleRow, +} from './types' diff --git a/packages/ui-patterns/src/Trace/types.ts b/packages/ui-patterns/src/Trace/types.ts new file mode 100644 index 00000000000..4417319137d --- /dev/null +++ b/packages/ui-patterns/src/Trace/types.ts @@ -0,0 +1,189 @@ +import type { Dispatch, ReactNode } from 'react' + +export type SpanStatus = 'ok' | 'error' | 'unset' + +export type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer' | (string & {}) + +export interface SpanEvent { + timeMs: number + name: string + attributes?: Record +} + +export interface SpanLink { + traceId: string + spanId: string +} + +export interface Span { + id: string + parentId: string | null + traceId: string + name: string + serviceName: string + kind: SpanKind + startMs: number + endMs: number | null + status: SpanStatus + attributes: Record + events: SpanEvent[] + links: SpanLink[] +} + +export interface TraceData { + traceId?: string + spans: readonly Span[] + nowMs?: number +} + +export type TimeWindow = readonly [start: number, end: number] + +export interface TraceIndex { + spans: readonly Span[] + byId: ReadonlyMap + childrenOf: ReadonlyMap + roots: readonly string[] + orphanIds: ReadonlySet + depthOf: ReadonlyMap + descendantCountOf: ReadonlyMap + bounds: TimeWindow + nowMs: number + serviceNames: readonly string[] +} + +export type FilterMatch = 'inactive' | 'match' | 'ancestor' + +export interface VisibleRow { + id: string + span: Span + index: number + depth: number + parentId: string | null + hasChildren: boolean + isExpanded: boolean + hiddenDescendantCount: number + isOrphan: boolean + siblingIndex: number + siblingCount: number + guides: readonly boolean[] + filterMatch: FilterMatch +} + +export interface TimeTick { + ms: number + fraction: number + x: number + label: string +} + +export interface TimeScale { + window: TimeWindow + origin: number + width: number + durationMs: number + pxPerMs: number + stepMs: number + ticks: readonly TimeTick[] + toFraction: (ms: number) => number + toX: (ms: number) => number + toMs: (x: number) => number + format: (ms: number) => string +} + +export interface TimeScaleOptions { + window: TimeWindow + width: number + origin?: number + targetTickSpacing?: number +} + +export type BarState = 'default' | 'hover' | 'selected' | 'dim' | 'running' + +export type BarClip = 'none' | 'start' | 'end' | 'both' + +export interface BarGeometry { + isVisible: boolean + isZeroDuration: boolean + clip: BarClip + leftFraction: number + widthFraction: number +} + +export type MarkerKind = 'log' | 'exception' | 'milestone' + +export type ValueKind = 'string' | 'number' | 'boolean' | 'null' | 'id' | 'url' | 'error' | 'json' + +export type KeyboardAction = + | { type: 'select'; id: string } + | { type: 'expand'; id: string } + | { type: 'collapse'; id: string } + +export type BrushEdge = 'start' | 'end' + +export type BrushMode = 'idle' | 'create' | 'move' | 'resize-start' | 'resize-end' + +export interface BrushRect { + left: number + width: number +} + +export interface FilterTerm { + key: string | null + value: string + negate: boolean +} + +export interface ViewState { + window: TimeWindow | null + selectedId: string | null + hoveredId: string | null + collapsed: ReadonlySet + query: string +} + +export type ViewAction = + | { type: 'setWindow'; window: TimeWindow | null } + | { type: 'select'; id: string | null } + | { type: 'hover'; id: string | null } + | { type: 'toggle'; id: string } + | { type: 'expand'; id: string } + | { type: 'collapse'; id: string } + | { type: 'collapseAll' } + | { type: 'expandAll' } + | { type: 'setCollapsed'; collapsed: ReadonlySet } + | { type: 'setQuery'; query: string } + +export type DataContextValue = TraceIndex + +export interface ViewContextValue { + window: TimeWindow + bounds: TimeWindow + isReset: boolean + selectedId: string | null + hoveredId: string | null + collapsed: ReadonlySet + query: string + matchIds: ReadonlySet | null + dispatch: Dispatch +} + +export interface SpanContextValue { + span: Span + row: VisibleRow + isSelected: boolean + isHovered: boolean + isRunning: boolean + state: BarState + readOnly: boolean +} + +export interface RootProps { + trace: TraceData + selectedId?: string | null + onSelectedIdChange?: (id: string | null) => void + window?: TimeWindow + onWindowChange?: (window: TimeWindow) => void + defaultCollapsed?: ReadonlySet + defaultQuery?: string + children?: ReactNode +} diff --git a/packages/ui-patterns/src/Trace/utils.test.ts b/packages/ui-patterns/src/Trace/utils.test.ts new file mode 100644 index 00000000000..e5cf332beca --- /dev/null +++ b/packages/ui-patterns/src/Trace/utils.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from 'vitest' + +import type { Span, TimeWindow } from './types' +import { + brushRect, + buildTraceIndex, + clampWindow, + collectAncestors, + computeBarGeometry, + computeMatchIds, + computeTicks, + computeTimeScale, + flattenTree, + formatMs, + indexRowsById, + isFullWindow, + matches, + matchTerms, + msToPx, + niceTickStep, + panWindow, + parseFilterQuery, + precisionForStep, + pxToMs, + resizeWindow, + resolveKeyboardAction, + windowFromDrag, + zoomWindow, +} from './utils' + +function makeSpan( + id: string, + parentId: string | null, + startMs: number, + endMs: number | null, + overrides: Partial = {} +): Span { + return { + id, + parentId, + traceId: 't', + name: id, + serviceName: 'svc', + kind: 'internal', + startMs, + endMs, + status: 'ok', + attributes: {}, + events: [], + links: [], + ...overrides, + } +} + +describe('niceTickStep', () => { + it('picks the smallest 1-2-5 step that fits the tick budget', () => { + expect(niceTickStep(1000, 10)).toBe(100) + expect(niceTickStep(1000, 25)).toBe(50) + expect(niceTickStep(412, 8)).toBe(100) + expect(niceTickStep(3, 6)).toBe(0.5) + }) + + it('switches to clock-friendly steps above one second', () => { + expect(niceTickStep(60_000, 5)).toBe(15_000) + expect(niceTickStep(10 * 60_000, 6)).toBe(2 * 60_000) + expect(niceTickStep(3 * 3_600_000, 4)).toBe(3_600_000) + }) + + it('extends beyond the table in whole-day multiples', () => { + const step = niceTickStep(10 * 24 * 3_600_000, 2) + expect(step % (24 * 3_600_000)).toBe(0) + }) + + it('returns 0 for degenerate input', () => { + expect(niceTickStep(0, 10)).toBe(0) + expect(niceTickStep(100, 0)).toBe(0) + }) +}) + +describe('formatMs', () => { + it('formats sub-millisecond values in microseconds', () => { + expect(formatMs(0)).toBe('0ms') + expect(formatMs(0.25)).toBe('250µs') + expect(formatMs(0.0042)).toBe('4.2µs') + }) + + it('formats milliseconds with fewer decimals as the value grows', () => { + expect(formatMs(1.234)).toBe('1.23ms') + expect(formatMs(12.34)).toBe('12.3ms') + expect(formatMs(123.4)).toBe('123ms') + }) + + it('formats seconds, minutes, and hours', () => { + expect(formatMs(1500)).toBe('1.5s') + expect(formatMs(65_000)).toBe('1m 5s') + expect(formatMs(120_000)).toBe('2m') + expect(formatMs(3_600_000 + 30 * 60_000)).toBe('1h 30m') + }) + + it('respects explicit precision and negative values', () => { + expect(formatMs(50.04, { precision: 2 })).toBe('50.04ms') + expect(formatMs(-250)).toBe('-250ms') + }) +}) + +describe('precisionForStep', () => { + it('derives label precision from the tick step', () => { + expect(precisionForStep(100)).toBe(0) + expect(precisionForStep(0.5)).toBe(1) + expect(precisionForStep(0.05)).toBe(2) + expect(precisionForStep(0.0005)).toBe(4) + }) +}) + +describe('computeTicks and computeTimeScale', () => { + it('aligns ticks to the origin, not to the window start', () => { + const { ticks, stepMs } = computeTicks({ + window: [1030, 1430], + width: 400, + origin: 1000, + targetTickSpacing: 80, + }) + expect(stepMs).toBe(100) + expect(ticks.map((tick) => tick.ms)).toEqual([1100, 1200, 1300, 1400]) + expect(ticks.map((tick) => tick.label)).toEqual(['100ms', '200ms', '300ms', '400ms']) + }) + + it('returns nothing without width or duration', () => { + expect( + computeTicks({ window: [0, 100], width: 0, origin: 0, targetTickSpacing: 80 }).ticks + ).toEqual([]) + expect( + computeTicks({ window: [5, 5], width: 100, origin: 0, targetTickSpacing: 80 }).ticks + ).toEqual([]) + }) + + it('maps ms to x and back', () => { + const scale = computeTimeScale({ window: [200, 700], width: 1000 }) + expect(scale.toX(450)).toBe(500) + expect(scale.toFraction(700)).toBe(1) + expect(scale.toMs(250)).toBe(325) + expect(scale.pxPerMs).toBe(2) + }) + + it('handles an empty window without dividing by zero', () => { + const scale = computeTimeScale({ window: [100, 100], width: 300 }) + expect(scale.toX(100)).toBe(0) + expect(scale.toMs(50)).toBe(100) + }) +}) + +const treeSpans: Span[] = [ + makeSpan('root', null, 0, 100), + makeSpan('b', 'root', 40, 60), + makeSpan('a', 'root', 10, 30), + makeSpan('a1', 'a', 12, 20), + makeSpan('a2', 'a', 8, 25), + makeSpan('b1', 'b', 45, 45), + makeSpan('orphan', 'ghost', 70, 80), + makeSpan('running', 'root', 90, null), +] + +describe('buildTraceIndex', () => { + const index = buildTraceIndex(treeSpans) + + it('sorts children by start time and treats orphans as roots', () => { + expect(index.roots).toEqual(['root', 'orphan']) + expect(index.childrenOf.get('root')).toEqual(['a', 'b', 'running']) + expect(index.childrenOf.get('a')).toEqual(['a2', 'a1']) + expect(index.orphanIds.has('orphan')).toBe(true) + }) + + it('computes depth and descendant counts', () => { + expect(index.depthOf.get('a1')).toBe(2) + expect(index.descendantCountOf.get('root')).toBe(6) + expect(index.descendantCountOf.get('a')).toBe(2) + }) + + it('derives bounds and extends them for running spans when nowMs is provided', () => { + expect(index.bounds).toEqual([0, 100]) + expect(buildTraceIndex(treeSpans, 150).bounds).toEqual([0, 150]) + expect(buildTraceIndex([]).bounds).toEqual([0, 0]) + }) +}) + +describe('flattenTree', () => { + const index = buildTraceIndex(treeSpans) + + it('flattens depth-first in start order when nothing is collapsed', () => { + const rows = flattenTree(index, new Set(), null) + expect(rows.map((row) => row.id)).toEqual([ + 'root', + 'a', + 'a2', + 'a1', + 'b', + 'b1', + 'running', + 'orphan', + ]) + expect(rows.map((row) => row.depth)).toEqual([0, 1, 2, 2, 1, 2, 1, 0]) + expect(rows.every((row) => row.filterMatch === 'inactive')).toBe(true) + }) + + it('hides descendants of collapsed nodes and reports how many are hidden', () => { + const rows = flattenTree(index, new Set(['a', 'root']), null) + expect(rows.map((row) => row.id)).toEqual(['root', 'orphan']) + expect(rows[0].hiddenDescendantCount).toBe(6) + const partial = flattenTree(index, new Set(['a']), null) + expect(partial.find((row) => row.id === 'a')?.hiddenDescendantCount).toBe(2) + }) + + it('computes sibling positions and depth guides', () => { + const rows = flattenTree(index, new Set(), null) + const a2 = rows.find((row) => row.id === 'a2')! + expect(a2.siblingIndex).toBe(0) + expect(a2.siblingCount).toBe(2) + expect(a2.guides).toEqual([true, true]) + expect(rows.find((row) => row.id === 'running')!.guides).toEqual([true]) + }) + + it('keeps matches and their ancestors when a filter is active', () => { + const rows = flattenTree(index, new Set(), new Set(['a1'])) + expect(rows.map((row) => row.id)).toEqual(['root', 'a', 'a1']) + expect(rows.map((row) => row.filterMatch)).toEqual(['ancestor', 'ancestor', 'match']) + }) + + it('counts hidden descendants within the filtered set', () => { + const rows = flattenTree(index, new Set(['root']), new Set(['a1', 'b1'])) + expect(rows.map((row) => row.id)).toEqual(['root']) + expect(rows[0].hiddenDescendantCount).toBe(4) + expect(flattenTree(index, new Set(), new Set())).toEqual([]) + }) + + it('collects ancestors up to the root and stops at missing parents', () => { + const keep = collectAncestors(index, new Set(['a1', 'orphan'])) + expect(Array.from(keep).sort()).toEqual(['a', 'a1', 'orphan', 'root']) + }) +}) + +describe('filter', () => { + const span = makeSpan('span-1', null, 0, 10, { + name: 'SELECT orders', + serviceName: 'postgres', + kind: 'client', + status: 'error', + attributes: { + 'db.statement': 'SELECT * FROM orders', + 'http.status_code': 502, + 'cache.hit': false, + 'user.id': null, + }, + events: [{ timeMs: 5, name: 'exception' }], + }) + + it('parses free text, key:value terms, quotes, and negation', () => { + expect(parseFilterQuery('orders status:error service=postgres')).toEqual([ + { key: null, value: 'orders', negate: false }, + { key: 'status', value: 'error', negate: false }, + { key: 'service', value: 'postgres', negate: false }, + ]) + expect(parseFilterQuery('db.statement:"SELECT * FROM" -status:ok -cache')).toEqual([ + { key: 'db.statement', value: 'SELECT * FROM', negate: false }, + { key: 'status', value: 'ok', negate: true }, + { key: null, value: 'cache', negate: true }, + ]) + expect(parseFilterQuery('-')).toEqual([{ key: null, value: '-', negate: false }]) + }) + + it('matches free text against name, service, id, attributes, and events', () => { + expect(matches(span, 'select')).toBe(true) + expect(matches(span, 'POSTGRES')).toBe(true) + expect(matches(span, '502')).toBe(true) + expect(matches(span, 'exception')).toBe(true) + expect(matches(span, 'redis')).toBe(false) + }) + + it('matches keyed terms and honours negation', () => { + expect(matches(span, 'status:error')).toBe(true) + expect(matches(span, 'cache.hit:false')).toBe(true) + expect(matches(span, 'user.id:null')).toBe(true) + expect(matches(span, 'missing.key:anything')).toBe(false) + expect(matches(span, 'select status:ok')).toBe(false) + expect(matchTerms(span, parseFilterQuery('-select'))).toBe(false) + }) + + it('computes match ids and treats an empty query as inactive', () => { + const other = makeSpan('span-2', null, 0, 5, { name: 'cache.get', serviceName: 'redis' }) + const index = buildTraceIndex([span, other]) + expect(computeMatchIds(index, ' ')).toBeNull() + expect(Array.from(computeMatchIds(index, 'status:error')!)).toEqual(['span-1']) + expect(computeMatchIds(index, 'nothing-here')!.size).toBe(0) + }) +}) + +describe('brush math', () => { + const bounds: TimeWindow = [1000, 2000] + + it('converts between px and ms', () => { + expect(msToPx(1500, bounds, 500)).toBe(250) + expect(pxToMs(125, bounds, 500)).toBe(1250) + expect(pxToMs(100, bounds, 0)).toBe(1000) + }) + + it('clamps windows inside bounds, preserving size and minimum duration', () => { + expect(clampWindow([900, 1200], bounds)).toEqual([1000, 1300]) + expect(clampWindow([1800, 2100], bounds)).toEqual([1700, 2000]) + expect(clampWindow([500, 2500], bounds)).toEqual([1000, 2000]) + expect(clampWindow([1500, 1500], bounds, 100)).toEqual([1450, 1550]) + expect(clampWindow([1600, 1200], bounds)).toEqual([1200, 1600]) + }) + + it('pans without resizing and stops at the edges', () => { + expect(panWindow([1200, 1400], 100, bounds)).toEqual([1300, 1500]) + expect(panWindow([1200, 1400], 5000, bounds)).toEqual([1800, 2000]) + }) + + it('resizes one edge and respects the minimum duration', () => { + expect(resizeWindow([1200, 1600], 'start', 1300, bounds)).toEqual([1300, 1600]) + expect(resizeWindow([1200, 1600], 'start', 1700, bounds, 50)).toEqual([1550, 1600]) + expect(resizeWindow([1200, 1600], 'end', 9999, bounds)).toEqual([1200, 2000]) + }) + + it('builds windows from drags and zooms around an anchor', () => { + expect(windowFromDrag(1600, 1200, bounds)).toEqual([1200, 1600]) + expect(zoomWindow([1200, 1600], 0.5, 1400, bounds)).toEqual([1300, 1500]) + expect(zoomWindow([1200, 1600], 10, undefined, bounds)).toEqual([1000, 2000]) + }) + + it('converts a window to a pixel rectangle and detects the full window', () => { + expect(brushRect([1250, 1750], bounds, 400)).toEqual({ left: 100, width: 200 }) + expect(isFullWindow([1000, 2000], bounds)).toBe(true) + expect(isFullWindow([1000, 1999], bounds)).toBe(false) + }) +}) + +describe('resolveKeyboardAction', () => { + const index = buildTraceIndex([ + makeSpan('root', null, 0, 10), + makeSpan('a', 'root', 1, 11), + makeSpan('a1', 'a', 2, 12), + makeSpan('b', 'root', 5, 15), + ]) + const setup = (collapsed: string[] = []) => { + const rows = flattenTree(index, new Set(collapsed), null) + return { rows, rowIndexById: indexRowsById(rows) } + } + + it('moves up and down and stops at the edges', () => { + const tree = setup() + expect(resolveKeyboardAction({ key: 'ArrowDown', ...tree, selectedId: null })).toEqual({ + type: 'select', + id: 'root', + }) + expect(resolveKeyboardAction({ key: 'ArrowDown', ...tree, selectedId: 'a' })).toEqual({ + type: 'select', + id: 'a1', + }) + expect(resolveKeyboardAction({ key: 'ArrowUp', ...tree, selectedId: 'root' })).toBeNull() + expect(resolveKeyboardAction({ key: 'ArrowDown', ...tree, selectedId: 'b' })).toBeNull() + }) + + it('expands on ArrowRight, then moves into the node', () => { + expect(resolveKeyboardAction({ key: 'ArrowRight', ...setup(['a']), selectedId: 'a' })).toEqual({ + type: 'expand', + id: 'a', + }) + expect(resolveKeyboardAction({ key: 'ArrowRight', ...setup(), selectedId: 'a' })).toEqual({ + type: 'select', + id: 'a1', + }) + expect(resolveKeyboardAction({ key: 'ArrowRight', ...setup(), selectedId: 'b' })).toBeNull() + }) + + it('collapses on ArrowLeft, otherwise jumps to the parent', () => { + const tree = setup() + expect(resolveKeyboardAction({ key: 'ArrowLeft', ...tree, selectedId: 'a' })).toEqual({ + type: 'collapse', + id: 'a', + }) + expect(resolveKeyboardAction({ key: 'ArrowLeft', ...tree, selectedId: 'a1' })).toEqual({ + type: 'select', + id: 'a', + }) + expect(resolveKeyboardAction({ key: 'ArrowLeft', ...tree, selectedId: 'b' })).toEqual({ + type: 'select', + id: 'root', + }) + }) + + it('jumps with Home and End and ignores other keys', () => { + const tree = setup() + expect(resolveKeyboardAction({ key: 'Home', ...tree, selectedId: 'b' })).toEqual({ + type: 'select', + id: 'root', + }) + expect(resolveKeyboardAction({ key: 'End', ...tree, selectedId: 'root' })).toEqual({ + type: 'select', + id: 'b', + }) + expect(resolveKeyboardAction({ key: 'Enter', ...tree, selectedId: 'a' })).toBeNull() + }) +}) + +describe('computeBarGeometry', () => { + const window: TimeWindow = [100, 200] + + it('positions and clips bars against the window', () => { + expect(computeBarGeometry(125, 175, window)).toMatchObject({ + clip: 'none', + leftFraction: 0.25, + widthFraction: 0.5, + }) + expect(computeBarGeometry(50, 150, window)).toMatchObject({ clip: 'start', leftFraction: 0 }) + expect(computeBarGeometry(150, 250, window)).toMatchObject({ clip: 'end', widthFraction: 0.5 }) + expect(computeBarGeometry(0, 300, window)).toMatchObject({ clip: 'both', widthFraction: 1 }) + }) + + it('hides bars outside the window and flags zero duration', () => { + expect(computeBarGeometry(0, 50, window).isVisible).toBe(false) + expect(computeBarGeometry(150, 150, window)).toMatchObject({ + isVisible: true, + isZeroDuration: true, + }) + expect(computeBarGeometry(150, 140, window).isZeroDuration).toBe(true) + expect(computeBarGeometry(100, 150, [100, 100]).isVisible).toBe(false) + }) +}) diff --git a/packages/ui-patterns/src/Trace/utils.ts b/packages/ui-patterns/src/Trace/utils.ts new file mode 100644 index 00000000000..b67a3c205a6 --- /dev/null +++ b/packages/ui-patterns/src/Trace/utils.ts @@ -0,0 +1,780 @@ +import type { + BarGeometry, + BrushEdge, + BrushRect, + FilterMatch, + FilterTerm, + KeyboardAction, + MarkerKind, + Span, + SpanEvent, + TimeScale, + TimeScaleOptions, + TimeTick, + TimeWindow, + TraceIndex, + ValueKind, + VisibleRow, +} from './types' + +// Trace index + +export function effectiveEndMs(span: Pick, nowMs: number): number { + if (span.endMs === null) return Math.max(span.startMs, nowMs) + return span.endMs +} + +export function durationOf(span: Pick, nowMs: number): number { + return Math.max(0, effectiveEndMs(span, nowMs) - span.startMs) +} + +function compareByStart(byId: ReadonlyMap) { + return (a: string, b: string) => { + const sa = byId.get(a)! + const sb = byId.get(b)! + if (sa.startMs !== sb.startMs) return sa.startMs - sb.startMs + return sa.id < sb.id ? -1 : sa.id > sb.id ? 1 : 0 + } +} + +export function buildTraceIndex(spans: readonly Span[], nowMs?: number): TraceIndex { + const byId = new Map() + for (const span of spans) byId.set(span.id, span) + + const childrenOf = new Map() + const roots: string[] = [] + const orphanIds = new Set() + const services = new Set() + + let minStart = Number.POSITIVE_INFINITY + let maxKnown = Number.NEGATIVE_INFINITY + + for (const span of byId.values()) { + services.add(span.serviceName) + if (span.startMs < minStart) minStart = span.startMs + if (span.startMs > maxKnown) maxKnown = span.startMs + if (span.endMs !== null && span.endMs > maxKnown) maxKnown = span.endMs + + const hasParent = span.parentId !== null && byId.has(span.parentId) + if (span.parentId !== null && !hasParent) orphanIds.add(span.id) + if (!hasParent) { + roots.push(span.id) + continue + } + const siblings = childrenOf.get(span.parentId!) + if (siblings) siblings.push(span.id) + else childrenOf.set(span.parentId!, [span.id]) + } + + const compare = compareByStart(byId) + roots.sort(compare) + for (const ids of childrenOf.values()) ids.sort(compare) + + const resolvedNow = nowMs ?? (Number.isFinite(maxKnown) ? maxKnown : 0) + + let maxEnd = maxKnown + for (const span of byId.values()) { + const end = effectiveEndMs(span, resolvedNow) + if (end > maxEnd) maxEnd = end + } + + const depthOf = new Map() + const descendantCountOf = new Map() + const postOrder: string[] = [] + const stack: Array<{ id: string; depth: number }> = [] + for (let i = roots.length - 1; i >= 0; i--) stack.push({ id: roots[i], depth: 0 }) + + while (stack.length > 0) { + const { id, depth } = stack.pop()! + if (depthOf.has(id)) continue + depthOf.set(id, depth) + postOrder.push(id) + const children = childrenOf.get(id) + if (!children) continue + for (let i = children.length - 1; i >= 0; i--) stack.push({ id: children[i], depth: depth + 1 }) + } + + for (let i = postOrder.length - 1; i >= 0; i--) { + const id = postOrder[i] + const children = childrenOf.get(id) + let count = 0 + if (children) { + for (const child of children) count += 1 + (descendantCountOf.get(child) ?? 0) + } + descendantCountOf.set(id, count) + } + + const bounds: TimeWindow = Number.isFinite(minStart) + ? [minStart, Math.max(maxEnd, minStart)] + : [0, 0] + + return { + spans, + byId, + childrenOf, + roots, + orphanIds, + depthOf, + descendantCountOf, + bounds, + nowMs: resolvedNow, + serviceNames: Array.from(services).sort(), + } +} + +export const EMPTY_TRACE_INDEX: TraceIndex = buildTraceIndex([]) + +// Tree flattening + +export function collectAncestors( + index: TraceIndex, + matchIds: ReadonlySet +): ReadonlySet { + const keep = new Set(matchIds) + for (const id of matchIds) { + let current = index.byId.get(id) + while (current && current.parentId !== null) { + const parent = index.byId.get(current.parentId) + if (!parent || keep.has(parent.id)) break + keep.add(parent.id) + current = parent + } + } + return keep +} + +interface Frame { + id: string + depth: number + siblingIndex: number + siblingCount: number + guides: boolean[] +} + +export function flattenTree( + index: TraceIndex, + collapsedIds: ReadonlySet, + matchIds: ReadonlySet | null +): VisibleRow[] { + const keep = matchIds ? collectAncestors(index, matchIds) : null + const rows: VisibleRow[] = [] + + const visibleChildren = (id: string): string[] => { + const children = index.childrenOf.get(id) + if (!children) return [] + if (!keep) return children as string[] + return children.filter((child) => keep.has(child)) + } + + const countVisibleDescendants = (id: string): number => { + if (!keep) return index.descendantCountOf.get(id) ?? 0 + let count = 0 + const stack = [...visibleChildren(id)] + while (stack.length > 0) { + const next = stack.pop()! + count++ + for (const child of visibleChildren(next)) stack.push(child) + } + return count + } + + const topLevel = keep ? index.roots.filter((id) => keep.has(id)) : index.roots + const stack: Frame[] = [] + for (let i = topLevel.length - 1; i >= 0; i--) { + stack.push({ + id: topLevel[i], + depth: 0, + siblingIndex: i, + siblingCount: topLevel.length, + guides: [], + }) + } + + while (stack.length > 0) { + const frame = stack.pop()! + const span = index.byId.get(frame.id) + if (!span) continue + + const children = visibleChildren(frame.id) + const hasChildren = children.length > 0 + const isCollapsed = collapsedIds.has(frame.id) + const isExpanded = hasChildren && !isCollapsed + + let filterMatch: FilterMatch = 'inactive' + if (matchIds) filterMatch = matchIds.has(frame.id) ? 'match' : 'ancestor' + + rows.push({ + id: frame.id, + span, + index: rows.length, + depth: frame.depth, + parentId: span.parentId !== null && index.byId.has(span.parentId) ? span.parentId : null, + hasChildren, + isExpanded, + hiddenDescendantCount: hasChildren && isCollapsed ? countVisibleDescendants(frame.id) : 0, + isOrphan: index.orphanIds.has(frame.id), + siblingIndex: frame.siblingIndex, + siblingCount: frame.siblingCount, + guides: frame.guides, + filterMatch, + }) + + if (!isExpanded) continue + + const hasFollowingSibling = frame.siblingIndex < frame.siblingCount - 1 + const childGuides = [...frame.guides, hasFollowingSibling] + for (let i = children.length - 1; i >= 0; i--) { + stack.push({ + id: children[i], + depth: frame.depth + 1, + siblingIndex: i, + siblingCount: children.length, + guides: childGuides, + }) + } + } + + return rows +} + +export function indexRowsById(rows: readonly VisibleRow[]): ReadonlyMap { + const map = new Map() + for (const row of rows) map.set(row.id, row.index) + return map +} + +// Time scale + +const SECOND = 1000 +const MINUTE = 60 * SECOND +const HOUR = 60 * MINUTE + +const SUB_SECOND_STEPS = [ + 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, +] + +const CLOCK_STEPS = [ + SECOND, + 2 * SECOND, + 5 * SECOND, + 10 * SECOND, + 15 * SECOND, + 30 * SECOND, + MINUTE, + 2 * MINUTE, + 5 * MINUTE, + 10 * MINUTE, + 15 * MINUTE, + 30 * MINUTE, + HOUR, + 2 * HOUR, + 3 * HOUR, + 6 * HOUR, + 12 * HOUR, + 24 * HOUR, +] + +export const NICE_STEPS_MS: readonly number[] = [...SUB_SECOND_STEPS, ...CLOCK_STEPS] + +export function niceTickStep(durationMs: number, maxTicks: number): number { + if (!(durationMs > 0) || !(maxTicks > 0)) return 0 + const raw = durationMs / maxTicks + for (const step of NICE_STEPS_MS) { + if (step >= raw) return step + } + const last = NICE_STEPS_MS[NICE_STEPS_MS.length - 1] + return Math.ceil(raw / last) * last +} + +function trimZeros(value: string): string { + return value.includes('.') ? value.replace(/\.?0+$/, '') : value +} + +export interface FormatMsOptions { + precision?: number +} + +export function formatMs(ms: number, options: FormatMsOptions = {}): string { + if (!Number.isFinite(ms)) return '–' + const sign = ms < 0 ? '-' : '' + const abs = Math.abs(ms) + const { precision } = options + + if (abs === 0) return '0ms' + if (abs < 1) { + const micro = abs * 1000 + const digits = precision ?? (micro < 10 ? 1 : 0) + return `${sign}${trimZeros(micro.toFixed(digits))}µs` + } + if (abs < SECOND) { + const digits = precision ?? (abs < 10 ? 2 : abs < 100 ? 1 : 0) + return `${sign}${trimZeros(abs.toFixed(digits))}ms` + } + if (abs < MINUTE) { + const digits = precision ?? 2 + return `${sign}${trimZeros((abs / SECOND).toFixed(digits))}s` + } + if (abs < HOUR) { + const minutes = Math.floor(abs / MINUTE) + const seconds = Math.round((abs - minutes * MINUTE) / SECOND) + return seconds === 0 ? `${sign}${minutes}m` : `${sign}${minutes}m ${seconds}s` + } + const hours = Math.floor(abs / HOUR) + const minutes = Math.round((abs - hours * HOUR) / MINUTE) + return minutes === 0 ? `${sign}${hours}h` : `${sign}${hours}h ${minutes}m` +} + +export function precisionForStep(stepMs: number): number { + if (stepMs <= 0) return 0 + if (stepMs < SECOND) return Math.max(0, -Math.floor(Math.log10(stepMs))) + if (stepMs < MINUTE) return Math.max(0, -Math.floor(Math.log10(stepMs / SECOND))) + return 0 +} + +export function computeTicks(options: Required): { + ticks: TimeTick[] + stepMs: number +} { + const { window, width, origin, targetTickSpacing } = options + const [start, end] = window + const durationMs = end - start + if (!(durationMs > 0) || !(width > 0)) return { ticks: [], stepMs: 0 } + + const maxTicks = Math.max(1, Math.floor(width / targetTickSpacing)) + const stepMs = niceTickStep(durationMs, maxTicks) + if (stepMs <= 0) return { ticks: [], stepMs: 0 } + + const precision = precisionForStep(stepMs) + const ticks: TimeTick[] = [] + const firstOffset = Math.ceil((start - origin) / stepMs - 1e-9) * stepMs + const guard = maxTicks + 2 + + for (let offset = firstOffset, i = 0; i < guard; offset += stepMs, i++) { + const ms = origin + offset + if (ms > end + 1e-9) break + if (ms < start - 1e-9) continue + const fraction = (ms - start) / durationMs + ticks.push({ + ms, + fraction, + x: fraction * width, + label: formatMs(ms - origin, { precision }), + }) + } + + return { ticks, stepMs } +} + +export function computeTimeScale(options: TimeScaleOptions): TimeScale { + const window = options.window + const width = Math.max(0, options.width) + const origin = options.origin ?? window[0] + const targetTickSpacing = options.targetTickSpacing ?? 80 + const durationMs = window[1] - window[0] + const pxPerMs = durationMs > 0 ? width / durationMs : 0 + + const { ticks, stepMs } = computeTicks({ window, width, origin, targetTickSpacing }) + + return { + window, + origin, + width, + durationMs, + pxPerMs, + stepMs, + ticks, + toFraction: (ms) => (durationMs > 0 ? (ms - window[0]) / durationMs : 0), + toX: (ms) => (durationMs > 0 ? ((ms - window[0]) / durationMs) * width : 0), + toMs: (x) => (width > 0 ? window[0] + (x / width) * durationMs : window[0]), + format: (ms) => formatMs(ms), + } +} + +// Filter + +export function parseFilterQuery(query: string): FilterTerm[] { + const terms: FilterTerm[] = [] + const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [] + for (const raw of tokens) { + let token = raw + let negate = false + if (token.startsWith('-') && token.length > 1) { + negate = true + token = token.slice(1) + } + const separator = token.search(/[:=]/) + if (separator > 0) { + const key = token.slice(0, separator) + const value = stripQuotes(token.slice(separator + 1)) + terms.push({ key, value, negate }) + } else { + terms.push({ key: null, value: stripQuotes(token), negate }) + } + } + return terms +} + +function stripQuotes(value: string): string { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1) + return value +} + +function includesInsensitive(haystack: string, needle: string): boolean { + return haystack.toLowerCase().includes(needle) +} + +function fieldValue(span: Span, key: string): unknown { + switch (key) { + case 'name': + return span.name + case 'service': + case 'serviceName': + return span.serviceName + case 'status': + return span.status + case 'kind': + return span.kind + case 'id': + return span.id + default: + return span.attributes[key] + } +} + +function matchTerm(span: Span, term: FilterTerm): boolean { + const needle = term.value.toLowerCase() + if (term.key !== null) { + const value = fieldValue(span, term.key) + if (value === undefined || value === null) return needle === 'null' && value === null + return needle === '' ? true : includesInsensitive(String(value), needle) + } + if (needle === '') return true + if (includesInsensitive(span.name, needle)) return true + if (includesInsensitive(span.serviceName, needle)) return true + if (includesInsensitive(span.id, needle)) return true + for (const value of Object.values(span.attributes)) { + if (typeof value === 'string' && includesInsensitive(value, needle)) return true + if (typeof value === 'number' && String(value).includes(needle)) return true + } + for (const event of span.events) { + if (includesInsensitive(event.name, needle)) return true + } + return false +} + +export function matchTerms(span: Span, terms: readonly FilterTerm[]): boolean { + for (const term of terms) { + const matched = matchTerm(span, term) + if (term.negate ? matched : !matched) return false + } + return true +} + +export function matches(span: Span, query: string): boolean { + const terms = parseFilterQuery(query) + if (terms.length === 0) return true + return matchTerms(span, terms) +} + +export function computeMatchIds(index: TraceIndex, query: string): ReadonlySet | null { + if (query.trim() === '') return null + const result = new Set() + for (const span of index.byId.values()) { + if (matches(span, query)) result.add(span.id) + } + return result +} + +// Brush + +export function boundsDuration(bounds: TimeWindow): number { + return Math.max(0, bounds[1] - bounds[0]) +} + +export function msToFraction(ms: number, bounds: TimeWindow): number { + const duration = boundsDuration(bounds) + if (duration === 0) return 0 + return (ms - bounds[0]) / duration +} + +export function fractionToMs(fraction: number, bounds: TimeWindow): number { + return bounds[0] + fraction * boundsDuration(bounds) +} + +export function msToPx(ms: number, bounds: TimeWindow, width: number): number { + return msToFraction(ms, bounds) * width +} + +export function pxToMs(px: number, bounds: TimeWindow, width: number): number { + if (width <= 0) return bounds[0] + return fractionToMs(px / width, bounds) +} + +export function resolveMinDuration(bounds: TimeWindow, minDurationMs?: number): number { + const duration = boundsDuration(bounds) + if (minDurationMs !== undefined) return Math.min(minDurationMs, duration) + return Math.min(duration, Math.max(duration / 1000, 0.001)) +} + +export function clampWindow( + window: TimeWindow, + bounds: TimeWindow, + minDurationMs?: number +): TimeWindow { + const minDuration = resolveMinDuration(bounds, minDurationMs) + const total = boundsDuration(bounds) + let [start, end] = window[0] <= window[1] ? window : [window[1], window[0]] + + if (end - start < minDuration) { + const center = (start + end) / 2 + start = center - minDuration / 2 + end = center + minDuration / 2 + } + if (end - start >= total) return [bounds[0], bounds[1]] + + if (start < bounds[0]) { + end += bounds[0] - start + start = bounds[0] + } + if (end > bounds[1]) { + start -= end - bounds[1] + end = bounds[1] + } + return [Math.max(start, bounds[0]), Math.min(end, bounds[1])] +} + +export function panWindow(window: TimeWindow, deltaMs: number, bounds: TimeWindow): TimeWindow { + const size = window[1] - window[0] + let start = window[0] + deltaMs + if (start < bounds[0]) start = bounds[0] + if (start + size > bounds[1]) start = bounds[1] - size + return [start, start + size] +} + +export function resizeWindow( + window: TimeWindow, + edge: BrushEdge, + ms: number, + bounds: TimeWindow, + minDurationMs?: number +): TimeWindow { + const minDuration = resolveMinDuration(bounds, minDurationMs) + const clampedMs = Math.min(Math.max(ms, bounds[0]), bounds[1]) + if (edge === 'start') { + const start = Math.min(clampedMs, window[1] - minDuration) + return [Math.max(start, bounds[0]), window[1]] + } + const end = Math.max(clampedMs, window[0] + minDuration) + return [window[0], Math.min(end, bounds[1])] +} + +export function windowFromDrag( + aMs: number, + bMs: number, + bounds: TimeWindow, + minDurationMs?: number +): TimeWindow { + const start = Math.min(aMs, bMs) + const end = Math.max(aMs, bMs) + return clampWindow([start, end], bounds, minDurationMs) +} + +export function zoomWindow( + window: TimeWindow, + factor: number, + anchorMs: number | undefined, + bounds: TimeWindow, + minDurationMs?: number +): TimeWindow { + if (!(factor > 0)) return window + const size = window[1] - window[0] + const anchor = anchorMs ?? (window[0] + window[1]) / 2 + const ratio = size > 0 ? (anchor - window[0]) / size : 0.5 + const nextSize = size * factor + const start = anchor - ratio * nextSize + return clampWindow([start, start + nextSize], bounds, minDurationMs) +} + +export function brushRect(window: TimeWindow, bounds: TimeWindow, width: number): BrushRect { + const left = msToPx(window[0], bounds, width) + const right = msToPx(window[1], bounds, width) + return { left, width: Math.max(0, right - left) } +} + +export function isFullWindow(window: TimeWindow, bounds: TimeWindow): boolean { + return window[0] <= bounds[0] && window[1] >= bounds[1] +} + +// Keyboard + +export interface ResolveKeyboardActionInput { + key: string + rows: readonly VisibleRow[] + selectedId: string | null + rowIndexById: ReadonlyMap +} + +export function resolveKeyboardAction(input: ResolveKeyboardActionInput): KeyboardAction | null { + const { key, rows, selectedId, rowIndexById } = input + if (rows.length === 0) return null + + const currentIndex = selectedId !== null ? (rowIndexById.get(selectedId) ?? -1) : -1 + const current = currentIndex >= 0 ? rows[currentIndex] : null + + switch (key) { + case 'ArrowDown': { + if (!current) return { type: 'select', id: rows[0].id } + const next = rows[Math.min(currentIndex + 1, rows.length - 1)] + return next.id === current.id ? null : { type: 'select', id: next.id } + } + case 'ArrowUp': { + if (!current) return { type: 'select', id: rows[rows.length - 1].id } + const prev = rows[Math.max(currentIndex - 1, 0)] + return prev.id === current.id ? null : { type: 'select', id: prev.id } + } + case 'Home': + return rows[0].id === selectedId ? null : { type: 'select', id: rows[0].id } + case 'End': { + const last = rows[rows.length - 1] + return last.id === selectedId ? null : { type: 'select', id: last.id } + } + case 'ArrowRight': { + if (!current) return { type: 'select', id: rows[0].id } + if (current.hasChildren && !current.isExpanded) return { type: 'expand', id: current.id } + if (current.isExpanded && currentIndex + 1 < rows.length) { + return { type: 'select', id: rows[currentIndex + 1].id } + } + return null + } + case 'ArrowLeft': { + if (!current) return { type: 'select', id: rows[0].id } + if (current.isExpanded) return { type: 'collapse', id: current.id } + if (current.parentId !== null && rowIndexById.has(current.parentId)) { + return { type: 'select', id: current.parentId } + } + return null + } + default: + return null + } +} + +export const NAVIGATION_KEYS: ReadonlySet = new Set([ + 'ArrowDown', + 'ArrowUp', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', +]) + +// Bar geometry + +export function computeBarGeometry( + startMs: number, + endMs: number, + window: TimeWindow +): BarGeometry { + const [windowStart, windowEnd] = window + const duration = windowEnd - windowStart + const end = Math.max(startMs, endMs) + const isZeroDuration = end === startMs + + if (!(duration > 0)) { + return { isVisible: false, isZeroDuration, clip: 'none', leftFraction: 0, widthFraction: 0 } + } + + const startFraction = (startMs - windowStart) / duration + const endFraction = (end - windowStart) / duration + + if (endFraction < 0 || startFraction > 1) { + return { isVisible: false, isZeroDuration, clip: 'none', leftFraction: 0, widthFraction: 0 } + } + + const clipStart = startFraction < 0 + const clipEnd = endFraction > 1 + let clip: BarGeometry['clip'] = 'none' + if (clipStart && clipEnd) clip = 'both' + else if (clipStart) clip = 'start' + else if (clipEnd) clip = 'end' + + const left = Math.max(0, startFraction) + const right = Math.min(1, endFraction) + + return { + isVisible: true, + isZeroDuration, + clip, + leftFraction: left, + widthFraction: Math.max(0, right - left), + } +} + +// Markers + +export function inferMarkerKind(event: SpanEvent): MarkerKind { + const name = event.name.toLowerCase() + const attributes = event.attributes ?? {} + if (name === 'exception' || name.includes('error') || 'exception.type' in attributes) { + return 'exception' + } + if (name === 'log' || name.startsWith('log.') || 'log.severity' in attributes) { + return 'log' + } + return 'milestone' +} + +export const MARKER_KIND_LABEL: Record = { + log: 'Log', + exception: 'Exception', + milestone: 'Milestone', +} + +// Key-value rendering + +const URL_PATTERN = /^https?:\/\/\S+$/i +const ID_KEY_PATTERN = /(^|[._-])(span_?id|trace_?id|parent_?(span_?)?id|id)$/i +const ERROR_KEY_PATTERN = /(exception|error|stack|stacktrace)/i + +export function detectValueKind(key: string, value: unknown): ValueKind { + if (value === null || value === undefined) return 'null' + if (typeof value === 'boolean') return 'boolean' + if (typeof value === 'number' || typeof value === 'bigint') return 'number' + if (typeof value === 'object') return 'json' + if (typeof value === 'string') { + if (URL_PATTERN.test(value)) return 'url' + if (ID_KEY_PATTERN.test(key)) return 'id' + if (ERROR_KEY_PATTERN.test(key)) return 'error' + return 'string' + } + return 'string' +} + +export function stringifyValue(value: unknown): string { + if (value === null || value === undefined) return 'null' + if (typeof value === 'string') return value + if (typeof value === 'bigint') return value.toString() + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } + } + return String(value) +} + +export function summarizeJson(value: unknown): string { + if (Array.isArray(value)) { + return value.length === 1 ? 'Array (1 item)' : `Array (${value.length} items)` + } + if (value && typeof value === 'object') { + const size = Object.keys(value).length + return size === 1 ? 'Object (1 key)' : `Object (${size} keys)` + } + return 'Value' +} + +export function sortedEntries(data: Record): Array<[string, unknown]> { + return Object.entries(data).sort(([a], [b]) => a.localeCompare(b)) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6abd4903100..cb962ada889 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2836,6 +2836,9 @@ importers: '@tanstack/react-table': specifier: 'catalog:' version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-virtual': + specifier: ^3.13.24 + version: 3.14.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1