feat: first draft of tracing ui primitives

This commit is contained in:
kemal committed 2026-10-02 13:20:26 +01:00
1 parent 1168fd8a21
commit 798bca9761
32 files changed
+4228

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+5
View File
@@ -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',
@@ -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
<ComponentPreview name="trace-ruler-demo" peekCode wide />
## Bar and Markers
<ComponentPreview name="trace-span-bar-demo" peekCode wide />
## Cell
<ComponentPreview name="trace-tree-cell-demo" peekCode wide />
## Minimap
<ComponentPreview name="trace-minimap-demo" peekCode wide />
## Inspector
<ComponentPreview name="trace-inspector-demo" peekCode wide />
## Composition
<ComponentPreview name="trace-waterfall-demo" peekCode wide />
### Compact, read-only embed
<ComponentPreview name="trace-waterfall-compact-demo" peekCode wide />
### Selection synced to the URL
<ComponentPreview name="trace-waterfall-url-sync-demo" peekCode wide />
@@ -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 (
<div className="flex w-full flex-col gap-4">
<ToggleGroup
type="single"
variant="segmented"
tone="text"
value={selectedId}
onValueChange={(value) => setSelectedId(value)}
allowDeselect={false}
aria-label="Inspected span"
>
{OPTIONS.map((option) => (
<ToggleGroupItem key={option.id} value={option.id}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<Trace.Root
trace={sample}
selectedId={selectedId === 'none' ? null : selectedId}
onSelectedIdChange={(id) => setSelectedId(id ?? 'none')}
>
<Trace.Inspector className="h-[420px] w-full max-w-md rounded-md border" />
</Trace.Root>
</div>
)
}
@@ -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<TimeWindow | undefined>(undefined)
const start = sample.spans[0].startMs
const bounds: TimeWindow = [start, sample.nowMs]
const current = window ?? bounds
return (
<div className="flex w-full flex-col gap-4">
<div className="flex flex-wrap items-center gap-4">
<Toggle variant="outline" size="sm" pressed={isLarge} onPressedChange={setIsLarge}>
5,000 spans (density mode)
</Toggle>
<label className="flex flex-1 items-center gap-2 text-xs text-foreground-light">
Window
<Slider
className="min-w-40 flex-1"
min={bounds[0]}
max={bounds[1]}
step={1}
minStepsBetweenThumbs={5}
value={[current[0], current[1]]}
onValueChange={([a, b]) => setWindow([a, b])}
/>
</label>
</div>
<Trace.Root trace={sample} window={window} onWindowChange={setWindow}>
<Trace.Minimap />
</Trace.Root>
</div>
)
}
@@ -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<TimeWindow>([0, 1000])
return (
<div className="flex w-full flex-col gap-6">
<label className="flex flex-col gap-2 text-xs text-foreground-light">
Window
<Slider
min={0}
max={1000}
step={1}
minStepsBetweenThumbs={10}
value={[window[0], window[1]]}
onValueChange={([start, end]) => setWindow([start, end])}
/>
</label>
<Trace.Root trace={trace} window={window} onWindowChange={setWindow}>
<Trace.Waterfall treeWidth={80}>
<Trace.Ruler />
</Trace.Waterfall>
</Trace.Root>
<div className="flex flex-col gap-2">
{[
{ durationMs: 2, label: 'Sub-millisecond' },
{ durationMs: 412, label: 'Request-sized' },
{ durationMs: 95 * 60_000, label: 'Minutes' },
].map(({ durationMs, label }) => (
<Trace.Root key={label} trace={traceOf(durationMs)}>
<Trace.Waterfall treeWidth={120}>
<Trace.Ruler>{label}</Trace.Ruler>
</Trace.Waterfall>
</Trace.Root>
))}
</div>
</div>
)
}
@@ -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<Pick<Span, 'attributes' | 'events' | 'links'>> = {}
): 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<SampleTraceOptions>): 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<SampleTraceOptions> = {
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<string, number>()
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 }
}
@@ -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> = {}): 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 className="w-44 shrink-0 self-center px-2 text-xs text-foreground-light">
{span.name}
</span>
)
}
function Gallery() {
return (
<Trace.Waterfall className="h-auto">
<Trace.Rows rowHeight={28} className="h-56" readOnly>
<Label />
<Trace.Lane>
<Trace.Bar />
<Trace.Markers />
</Trace.Lane>
</Trace.Rows>
</Trace.Waterfall>
)
}
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 (
<div className="flex w-full flex-col gap-4">
<div className="flex flex-wrap items-center gap-4">
<Toggle variant="outline" size="sm" pressed={isZoomed} onPressedChange={setIsZoomed}>
Zoom window (clips bars)
</Toggle>
<label className="flex flex-1 items-center gap-2 text-xs text-foreground-light">
Window start
<Slider
className="min-w-40 flex-1"
min={0}
max={250}
step={5}
disabled={!isZoomed}
value={[offset]}
onValueChange={([value]) => setOffset(value)}
/>
</label>
</div>
<Trace.Root trace={{ spans, nowMs: sample.nowMs }} window={window} selectedId="selected">
<Gallery />
</Trace.Root>
<Trace.Root
trace={{ spans: filteredSpans, nowMs: sample.nowMs }}
window={window}
defaultQuery="name:match"
>
<Trace.Waterfall className="h-auto">
<Trace.Rows rowHeight={28} className="h-16" readOnly>
<Label />
<Trace.Lane>
<Trace.Bar />
</Trace.Lane>
</Trace.Rows>
</Trace.Waterfall>
</Trace.Root>
</div>
)
}
@@ -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 (
<div className="flex w-full flex-col gap-4">
<label className="flex items-center gap-2 text-xs text-foreground-light">
Indent {indent}px
<Slider
className="min-w-40 flex-1"
min={8}
max={32}
step={2}
value={[indent]}
onValueChange={([value]) => setIndent(value)}
/>
</label>
<Trace.Root trace={sample} defaultCollapsed={defaultCollapsed}>
<Trace.Waterfall treeWidth={420} className="h-96 max-w-md">
<Trace.Rows rowHeight={28}>
<Trace.Cell indent={indent} className="border-r-0" />
</Trace.Rows>
</Trace.Waterfall>
</Trace.Root>
</div>
)
}
@@ -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 (
<Trace.Root trace={sample}>
<Trace.Waterfall treeWidth={200} className="h-56 w-full">
<Trace.Ruler />
<Trace.Rows rowHeight={22} readOnly>
<Trace.Cell indent={10} />
<Trace.Lane>
<Trace.Bar />
</Trace.Lane>
</Trace.Rows>
</Trace.Waterfall>
</Trace.Root>
)
}
@@ -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<string | null>('span-0008')
return (
<div className="flex w-full flex-col gap-3">
<Trace.Root trace={sample} selectedId={selectedId} onSelectedIdChange={setSelectedId}>
<Trace.Header>
<Trace.Title />
<Trace.Status />
<Trace.Filter placeholder="Filter spans, e.g. status:error" />
<Trace.CollapseAll />
<Trace.ResetZoom />
<Toggle
variant="outline"
size="sm"
className="ml-auto"
pressed={isLarge}
onPressedChange={setIsLarge}
>
100k spans
</Toggle>
</Trace.Header>
<Trace.Minimap />
<div className="flex h-[480px] overflow-hidden rounded-md border border-default">
<Trace.Waterfall treeWidth={320} className="min-w-0 flex-1 rounded-none border-0">
<Trace.Ruler />
<Trace.Rows rowHeight={28}>
<Trace.Cell />
<Trace.Lane>
<Trace.Bar />
<Trace.Markers />
</Trace.Lane>
</Trace.Rows>
</Trace.Waterfall>
<Trace.Inspector className="w-[340px] shrink-0" />
</div>
</Trace.Root>
</div>
)
}
@@ -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 (
<div className="flex w-full flex-col gap-2">
<p className="font-mono text-xs text-foreground-light">
?{PARAM}={selectedId ?? '(none)'}
</p>
<Trace.Root trace={sample} selectedId={selectedId} onSelectedIdChange={setSelectedId}>
<div className="flex h-72 overflow-hidden rounded-md border border-default">
<Trace.Waterfall treeWidth={240} className="min-w-0 flex-1 rounded-none border-0">
<Trace.Ruler />
<Trace.Rows rowHeight={26}>
<Trace.Cell />
<Trace.Lane>
<Trace.Bar />
</Trace.Lane>
</Trace.Rows>
</Trace.Waterfall>
<Trace.Inspector className="w-72 shrink-0" />
</div>
</Trace.Root>
</div>
)
}
+46
View File
@@ -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'],
},
]
+61
View File
@@ -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:*",
+142
View File
@@ -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 (
<div
data-trace-bar
data-state={state}
data-status={span.status}
data-service={span.serviceName}
data-clip={geometry.clip}
data-zero-duration={geometry.isZeroDuration ? '' : undefined}
data-running={isRunning ? '' : undefined}
className={cn(
'absolute top-1/2 h-[55%] min-w-[2px] -translate-y-1/2 rounded-sm bg-current',
'data-[status=ok]:text-brand data-[status=error]:text-destructive data-[status=unset]:text-foreground-muted',
'transition-[opacity,box-shadow] motion-reduce:transition-none',
'data-[state=hover]:brightness-110',
'data-[state=selected]:ring-2 data-[state=selected]:ring-foreground data-[state=selected]:ring-offset-1 data-[state=selected]:ring-offset-background',
'data-[state=dim]:opacity-40',
'data-[clip=start]:rounded-l-none data-[clip=end]:rounded-r-none data-[clip=both]:rounded-none',
'data-[zero-duration]:w-[2px]',
'data-[running]:bg-[repeating-linear-gradient(135deg,currentColor_0_6px,transparent_6px_10px)] data-[running]:motion-safe:animate-pulse',
className
)}
style={{
left: `${geometry.leftFraction * 100}%`,
width: geometry.isZeroDuration ? undefined : `${geometry.widthFraction * 100}%`,
...style,
}}
{...props}
>
{children}
</div>
)
}
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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={-1}
aria-label={`${kindLabel} at ${formatMs(event.timeMs - bounds[0])}`}
data-trace-marker
data-kind={kind}
className="absolute top-1/2 flex size-3 -translate-x-1/2 -translate-y-1/2 items-center justify-center focus-ring"
style={{ left: `${fraction * 100}%` }}
onClick={(e) => e.stopPropagation()}
>
{kind === 'exception' && <CircleAlert className="size-3 text-destructive" />}
{kind === 'log' && (
<span aria-hidden className="size-1.5 rounded-full bg-foreground-lighter" />
)}
{kind === 'milestone' && (
<span aria-hidden className="size-1.5 rotate-45 bg-foreground" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{event.name}
<span className="text-foreground-light"> · +{formatMs(offsetMs)}</span>
</TooltipContent>
</Tooltip>
)
}
export function Markers() {
const { span } = useSpan()
if (span.events.length === 0) return null
return (
<>
{span.events.map((event, index) => (
<Marker
key={`${event.timeMs}-${event.name}-${index}`}
event={event}
kind={inferMarkerKind(event)}
offsetMs={event.timeMs - span.startMs}
/>
))}
</>
)
}
export function Duration({ className, ...props }: ComponentProps<'span'>) {
const { span, isRunning } = useSpan()
const { nowMs } = useData()
const duration = formatMs(durationOf(span, nowMs))
return (
<span
data-trace-duration
className={cn(
'shrink-0 font-mono text-xs tabular-nums text-foreground-lighter',
isRunning && 'text-foreground-light',
className
)}
{...props}
>
{isRunning ? `${duration}…` : duration}
</span>
)
}
+119
View File
@@ -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 (
<div
data-trace-cell
className={cn(
'relative flex w-(--trace-tree-width) shrink-0 items-center gap-1.5 overflow-hidden border-r border-muted pr-2',
className
)}
style={{ paddingLeft: row.depth * indent + 4, ...style }}
{...props}
>
{row.guides.map((show, depth) =>
show ? (
<span
key={depth}
aria-hidden
data-trace-guide
className="absolute inset-y-0 w-px bg-border-muted"
style={{ left: depth * indent + guideOffset }}
/>
) : null
)}
<span className="flex shrink-0 items-center justify-center" style={{ width: TOGGLE_SIZE }}>
{row.hasChildren && (
<Button
variant="text"
size="tiny"
tabIndex={-1}
aria-label={row.isExpanded ? 'Collapse' : 'Expand'}
disabled={readOnly}
className="size-[18px] p-0"
icon={
<ChevronRight
className={cn(
'transition-transform motion-reduce:transition-none',
row.isExpanded && 'rotate-90'
)}
/>
}
onClick={(event) => {
event.stopPropagation()
dispatch({ type: 'toggle', id: row.id })
}}
/>
)}
</span>
<span
aria-hidden
data-trace-status-dot
data-status={span.status}
className={cn(
'size-1.5 shrink-0 rounded-full',
span.status === 'error' && 'bg-destructive',
span.status === 'ok' && 'bg-brand',
span.status === 'unset' && 'border border-foreground-muted'
)}
/>
<Tooltip>
<TooltipTrigger asChild>
<span data-trace-name className="min-w-0 flex-1 truncate">
{span.name}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start">
{span.name}
<span className="text-foreground-light"> · {span.serviceName}</span>
</TooltipContent>
</Tooltip>
<span
data-trace-service
data-service={span.serviceName}
className="max-w-[30%] shrink truncate text-xs text-foreground-lighter"
>
{span.serviceName}
</span>
{row.isOrphan && (
<Tooltip>
<TooltipTrigger asChild>
<span data-trace-orphan className="flex shrink-0 text-warning">
<Unplug className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent side="top">Parent span is missing from this trace</TooltipContent>
</Tooltip>
)}
{row.hiddenDescendantCount > 0 && (
<Badge data-trace-hidden-count>+{row.hiddenDescendantCount}</Badge>
)}
<Duration className="ml-auto" />
</div>
)
}
+145
View File
@@ -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 (
<div
role="toolbar"
aria-label="Trace tools"
data-trace-header
className={cn('flex flex-wrap items-center gap-2', className)}
{...props}
>
{children}
</div>
)
}
export function Title({ className, children, ...props }: ComponentProps<'h2'>) {
const { roots, byId } = useData()
const root = roots.length > 0 ? byId.get(roots[0]) : undefined
return (
<h2
data-trace-title
className={cn('truncate text-sm font-medium text-foreground', className)}
{...props}
>
{children ?? root?.name ?? 'Trace'}
</h2>
)
}
export const STATUS_BADGE_VARIANT: Record<SpanStatus, BadgeProps['variant']> = {
ok: 'success',
error: 'destructive',
unset: 'default',
}
export function Status({ className, children, ...props }: Omit<BadgeProps, 'variant'>) {
const { roots, byId } = useData()
const root = roots.length > 0 ? byId.get(roots[0]) : undefined
const status: SpanStatus = root?.status ?? 'unset'
return (
<Badge
variant={STATUS_BADGE_VARIANT[status]}
data-trace-status
data-status={status}
className={className}
{...props}
>
{children ?? status}
</Badge>
)
}
export interface FilterProps extends Omit<ComponentProps<'input'>, 'value' | 'onChange' | 'size'> {
className?: string
}
export function Filter({
className,
placeholder = 'Filter spans',
onKeyDown,
...props
}: FilterProps) {
const { query, matchIds, dispatch } = useView()
return (
<InputGroup data-trace-filter className={cn('w-64', className)}>
<InputGroupAddon>
<Search className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
type="search"
aria-label="Filter spans"
placeholder={placeholder}
value={query}
onChange={(event) => 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 && (
<InputGroupAddon align="inline-end">
<span
data-trace-filter-count
className="font-mono text-xs tabular-nums text-foreground-lighter"
>
{matchIds.size}
</span>
</InputGroupAddon>
)}
</InputGroup>
)
}
export function CollapseAll({ children, onClick, ...props }: ButtonProps) {
const { dispatch } = useView()
return (
<Button
variant="default"
size="tiny"
icon={<ChevronsDownUp />}
data-trace-collapse-all
onClick={(event) => {
onClick?.(event)
if (!event.defaultPrevented) dispatch({ type: 'collapseAll' })
}}
{...props}
>
{children ?? 'Collapse all'}
</Button>
)
}
export function ResetZoom({ children, onClick, ...props }: ButtonProps) {
const { isReset, dispatch } = useView()
return (
<Button
variant="default"
size="tiny"
icon={<Maximize2 />}
disabled={isReset}
data-trace-reset-zoom
onClick={(event) => {
onClick?.(event)
if (!event.defaultPrevented) dispatch({ type: 'setWindow', window: null })
}}
{...props}
>
{children ?? 'Reset zoom'}
</Button>
)
}
@@ -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 <span className="text-foreground-muted">null</span>
case 'boolean':
return (
<span className={cn(value ? 'text-brand' : 'text-foreground-light')}>{String(value)}</span>
)
case 'number':
return <span className="tabular-nums">{stringifyValue(value)}</span>
case 'id': {
const id = String(value)
if (!byId.has(id) || /trace/i.test(entryKey)) return <span className="break-all">{id}</span>
return (
<Button
variant="link"
size="tiny"
className="h-auto p-0 font-mono text-xs"
iconRight={<CornerDownRight />}
onClick={() => dispatch({ type: 'select', id })}
>
{id}
</Button>
)
}
case 'url': {
const href = String(value)
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full items-center gap-1 break-all text-brand underline-offset-2 hover:underline focus-ring"
>
<span className="min-w-0 break-all">{href}</span>
<ExternalLink className="size-3 shrink-0" />
</a>
)
}
case 'error':
return (
<pre className="whitespace-pre-wrap break-words font-mono text-destructive">
{String(value)}
</pre>
)
case 'json':
return (
<Collapsible className="group/json">
<CollapsibleTrigger asChild>
<Button
variant="text"
size="tiny"
className="h-auto px-1 py-0.5 font-mono text-xs"
iconLeft={
<ChevronRight className="transition-transform group-data-[state=open]/json:rotate-90 motion-reduce:transition-none" />
}
>
{summarizeJson(value)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-surface-200 p-2 text-xs">
{stringifyValue(value)}
</pre>
</CollapsibleContent>
</Collapsible>
)
default:
return <span className="whitespace-pre-wrap break-all">{String(value)}</span>
}
}
interface KeyValueProps {
data: Record<string, unknown>
emptyMessage?: ReactNode
className?: string
}
function KeyValue({ data, emptyMessage = 'No attributes', className }: KeyValueProps) {
const entries = sortedEntries(data)
if (entries.length === 0) {
return (
<div
data-trace-key-value
data-empty
className={cn('px-3 py-2 text-xs text-foreground-light', className)}
>
{emptyMessage}
</div>
)
}
return (
<div data-trace-key-value className={cn('text-xs', className)}>
<Table containerProps={{ className: 'overflow-visible' }}>
<TableBody>
{entries.map(([key, value]) => {
const kind = detectValueKind(key, value)
return (
<TableRow key={key} data-trace-key-value-row data-kind={kind} className="group/kv">
<TableCell className="w-[38%] min-w-0 py-1.5 pl-3 pr-2 align-top">
<span className="block break-all font-mono text-foreground-light">{key}</span>
</TableCell>
<TableCell className="min-w-0 py-1.5 pl-2 pr-2 align-top font-mono">
<div className="flex items-start gap-1">
<div className="min-w-0 flex-1">
<Value entryKey={key} value={value} kind={kind} />
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="tiny"
aria-label={`Copy ${key}`}
className="size-5 shrink-0 p-0 opacity-0 group-hover/kv:opacity-100 focus-visible:opacity-100"
icon={<Copy />}
onClick={() => copy(stringifyValue(value))}
/>
</TooltipTrigger>
<TooltipContent side="left">Copy value</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
interface SectionProps {
title: ReactNode
count?: number
children: ReactNode
}
function Section({ title, count, children }: SectionProps) {
return (
<section data-trace-inspector-section className="border-b border-muted last:border-b-0">
<h3 className="flex items-center gap-2 px-3 pb-1 pt-3 text-xs font-medium text-foreground-light">
{title}
{count !== undefined && (
<span className="font-mono tabular-nums text-foreground-lighter">{count}</span>
)}
</h3>
<div className="pb-2">{children}</div>
</section>
)
}
function SpanHeader({ span }: { span: Span }) {
const { nowMs, bounds } = useData()
const isRunning = span.endMs === null
const duration = formatMs(durationOf(span, nowMs))
return (
<header
data-trace-inspector-header
className="flex flex-col gap-2 border-b border-default px-3 py-3"
>
<div className="flex flex-wrap items-center gap-1.5">
<Badge data-service={span.serviceName} className="normal-case tracking-normal">
{span.serviceName}
</Badge>
<Badge variant={STATUS_BADGE_VARIANT[span.status]} data-status={span.status}>
{span.status}
</Badge>
<Badge>{span.kind}</Badge>
{isRunning && <Badge>running</Badge>}
</div>
<Tooltip>
<TooltipTrigger asChild>
<h2 className="truncate text-sm font-medium text-foreground">{span.name}</h2>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
{span.name}
</TooltipContent>
</Tooltip>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 font-mono text-xs">
<dt className="text-foreground-light">Duration</dt>
<dd className="tabular-nums text-foreground">{isRunning ? `${duration}…` : duration}</dd>
<dt className="text-foreground-light">Started</dt>
<dd className="tabular-nums text-foreground">+{formatMs(span.startMs - bounds[0])}</dd>
<dt className="text-foreground-light">Span ID</dt>
<dd className="flex min-w-0 items-center gap-1 text-foreground">
<span className="truncate">{span.id}</span>
<Button
variant="text"
size="tiny"
aria-label="Copy span ID"
className="size-5 shrink-0 p-0"
icon={<Copy />}
onClick={() => copy(span.id)}
/>
</dd>
</dl>
</header>
)
}
function Events({ span }: { span: Span }) {
return (
<Section title="Events" count={span.events.length}>
{span.events.length === 0 && (
<p className="px-3 py-2 text-xs text-foreground-light">No events</p>
)}
{span.events.length > 0 && (
<ol className="flex flex-col">
{span.events.map((event, index) => {
const kind = inferMarkerKind(event)
const hasAttributes = event.attributes && Object.keys(event.attributes).length > 0
return (
<li
key={`${event.timeMs}-${event.name}-${index}`}
data-trace-inspector-event
data-kind={kind}
className="flex flex-col gap-1 border-t border-muted px-3 py-2 first:border-t-0"
>
<div className="flex items-center gap-2 text-xs">
<span className="w-16 shrink-0 font-mono tabular-nums text-foreground-lighter">
+{formatMs(event.timeMs - span.startMs)}
</span>
{kind === 'exception' && (
<CircleAlert className="size-3 shrink-0 text-destructive" />
)}
<span
className={cn(
'truncate font-mono',
kind === 'exception' ? 'text-destructive' : 'text-foreground'
)}
>
{event.name}
</span>
</div>
{hasAttributes && <KeyValue data={event.attributes ?? {}} className="-mx-3" />}
</li>
)
})}
</ol>
)}
</Section>
)
}
function Links({ span }: { span: Span }) {
const { byId } = useData()
const { dispatch } = useView()
if (span.links.length === 0) return null
return (
<Section title="Links" count={span.links.length}>
<ul className="flex flex-col">
{span.links.map((link) => (
<li
key={`${link.traceId}:${link.spanId}`}
data-trace-inspector-link
className="flex items-center gap-2 px-3 py-1 font-mono text-xs"
>
<span className="text-foreground-light">trace</span>
<span className="truncate">{link.traceId}</span>
<span className="text-foreground-light">span</span>
{byId.has(link.spanId) ? (
<Button
variant="link"
size="tiny"
className="h-auto min-w-0 p-0 font-mono text-xs"
iconRight={<CornerDownRight />}
onClick={() => dispatch({ type: 'select', id: link.spanId })}
>
<span className="truncate">{link.spanId}</span>
</Button>
) : (
<span className="truncate">{link.spanId}</span>
)}
</li>
))}
</ul>
</Section>
)
}
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 (
<aside
role="complementary"
aria-label="Span details"
data-trace-inspector
data-empty={span ? undefined : ''}
className={cn(
'flex min-h-0 flex-col overflow-hidden border-l border-default bg-surface-100 text-sm',
className
)}
{...props}
>
{!span && (
<div
data-trace-inspector-empty
className="flex flex-1 items-center justify-center p-6 text-center text-sm text-foreground-light"
>
{emptyMessage ?? 'Select a span to see its details'}
</div>
)}
{span && (
<>
<SpanHeader span={span} />
<ScrollArea className="min-h-0 flex-1">
<Section title="Attributes" count={Object.keys(span.attributes).length}>
<KeyValue data={span.attributes} />
</Section>
<Events span={span} />
<Links span={span} />
</ScrollArea>
</>
)}
</aside>
)
}
+226
View File
@@ -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<number>(bucketCount + 1).fill(0)
const errors = new Array<number>(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<SpanStatus, string> = {
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<HTMLDivElement>()
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 (
<div
data-trace-minimap
data-mode={shapes.kind}
className={cn(
'relative w-full select-none overflow-hidden rounded-md border border-default bg-surface-100',
className
)}
style={{ height }}
{...props}
>
<svg
aria-hidden
className="absolute inset-0 size-full"
viewBox={`0 0 ${VIEWBOX_WIDTH} ${height}`}
preserveAspectRatio="none"
>
{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 (
<rect
key={item.id}
x={x}
y={lane * laneHeight}
width={w}
height={Math.max(1, laneHeight - 1)}
className={cn('opacity-70', STATUS_FILL[item.status])}
/>
)
})}
{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 (
<g key={bucket.x}>
<rect
x={bucket.x * VIEWBOX_WIDTH}
y={height - barHeight}
width={barWidth}
height={barHeight}
className="fill-foreground-muted opacity-40"
/>
{errorHeight > 0 && (
<rect
x={bucket.x * VIEWBOX_WIDTH}
y={height - errorHeight}
width={barWidth}
height={errorHeight}
className="fill-destructive opacity-60"
/>
)}
</g>
)
})}
</svg>
<div
ref={ref}
data-trace-brush-track
className="absolute inset-0 cursor-crosshair"
{...brush.getTrackProps()}
>
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 bg-background/60"
style={{ width: `${leftShade * 100}%` }}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 bg-background/60"
style={{ width: `${rightShade * 100}%` }}
/>
<div
role="group"
aria-label="Visible time window"
data-trace-brush
data-dragging={brush.isDragging ? '' : undefined}
data-full={brush.isFull ? '' : undefined}
className={cn(
'absolute inset-y-0 cursor-grab border-x border-foreground-muted bg-foreground/5 focus-inset',
'data-[dragging]:cursor-grabbing data-[dragging]:bg-foreground/10'
)}
style={{ left: brush.rect.left, width: brush.rect.width }}
{...brush.getBrushProps()}
>
<div
aria-label="Window start"
aria-valuetext={formatMs(window[0] - bounds[0])}
data-trace-brush-handle="start"
className="absolute inset-y-0 -left-1 w-2 cursor-ew-resize focus-ring"
{...brush.getHandleProps('start')}
>
<span className="absolute inset-y-[25%] left-[3px] w-0.5 rounded bg-foreground-muted" />
</div>
<div
aria-label="Window end"
aria-valuetext={formatMs(window[1] - bounds[0])}
data-trace-brush-handle="end"
className="absolute inset-y-0 -right-1 w-2 cursor-ew-resize focus-ring"
{...brush.getHandleProps('end')}
>
<span className="absolute inset-y-[25%] right-[3px] w-0.5 rounded bg-foreground-muted" />
</div>
</div>
</div>
</div>
)
}
+172
View File
@@ -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<DataContextValue | null>(null)
export const ViewContext = createContext<ViewContextValue | null>(null)
const EMPTY_SET: ReadonlySet<string> = 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<string>()
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<TimeWindow>(() => {
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<ViewContextValue>(
() => ({
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 (
<DataContext.Provider value={index}>
<ViewContext.Provider value={view}>{children}</ViewContext.Provider>
</DataContext.Provider>
)
}
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
}
@@ -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 (
<div
data-trace-waterfall
className={cn(
'flex min-h-0 flex-col overflow-hidden rounded-md border border-default bg-surface-100 text-sm text-foreground',
className
)}
style={{ ...vars, ...style }}
{...props}
>
{children}
</div>
)
}
export interface RulerProps extends ComponentProps<'div'> {
targetTickSpacing?: number
}
export function Ruler({ targetTickSpacing = 80, className, children, ...props }: RulerProps) {
const { ref, scale } = useTimeScale<HTMLDivElement>({ targetTickSpacing })
return (
<div
data-trace-ruler
className={cn(
'flex h-7 shrink-0 items-stretch border-b border-default bg-surface-200',
className
)}
{...props}
>
<div className="flex w-(--trace-tree-width) shrink-0 items-center gap-2 border-r border-default px-2 text-xs text-foreground-light">
{children ?? 'Span'}
</div>
<div ref={ref} aria-hidden className="relative min-w-0 flex-1 select-none overflow-hidden">
{scale.ticks.map((tick) => (
<div
key={tick.ms}
data-trace-tick
className="absolute inset-y-0 border-l border-strong"
style={{ left: `${tick.fraction * 100}%` }}
>
<span className="absolute left-1 top-1/2 -translate-y-1/2 whitespace-nowrap font-mono text-[10px] tabular-nums text-foreground-lighter">
{tick.label}
</span>
</div>
))}
</div>
</div>
)
}
interface RowsContextValue {
readOnly: boolean
focusableId: string | null
}
const RowsContext = createContext<RowsContextValue>({ 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<SpanContextValue>(
() => ({ span, row, isSelected, isHovered, isRunning, state, readOnly }),
[span, row, isSelected, isHovered, isRunning, state, readOnly]
)
return (
<SpanContext.Provider value={value}>
<div
role="treeitem"
aria-level={row.depth + 1}
aria-setsize={row.siblingCount}
aria-posinset={row.siblingIndex + 1}
aria-expanded={row.hasChildren ? row.isExpanded : undefined}
aria-selected={readOnly ? undefined : isSelected}
tabIndex={readOnly ? -1 : row.id === focusableId ? 0 : -1}
data-trace-row
data-trace-row-id={row.id}
data-state={state}
data-status={span.status}
data-service={span.serviceName}
data-depth={row.depth}
data-filter={row.filterMatch}
data-running={isRunning ? '' : undefined}
data-orphan={row.isOrphan ? '' : undefined}
className={cn(
'flex h-(--trace-row-height) w-full items-stretch border-b border-muted focus-inset',
!readOnly && 'cursor-pointer hover:bg-surface-200',
'data-[state=selected]:bg-selection',
'data-[filter=ancestor]:opacity-60'
)}
onClick={() => {
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}
</div>
</SpanContext.Provider>
)
}
export interface RowsProps extends ComponentProps<typeof ScrollAreaPrimitive.Root> {
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<HTMLDivElement>(null)
const treeRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLElement>(
`[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<RowsContextValue>(
() => ({ 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 (
<ScrollAreaPrimitive.Root
data-trace-rows
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
{...props}
>
<ScrollViewport ref={viewportRef} className="h-full w-full">
<RowsContext.Provider value={rowsContext}>
{rows.length === 0 && (
<div
data-trace-empty
className="flex items-center justify-center p-6 text-center text-sm text-foreground-light"
>
{fallback}
</div>
)}
<div
ref={treeRef}
role="tree"
aria-label={ariaLabel}
aria-multiselectable={false}
className="relative w-full"
style={{ ...vars, height: virtualizer.getTotalSize() }}
onKeyDown={handleKeyDown}
>
{virtualizer.getVirtualItems().map((item) => {
const row = rows[item.index]
if (!row) return null
return (
<div
key={item.key}
role="none"
data-index={item.index}
className="absolute left-0 top-0 w-full"
style={{ height: item.size, transform: `translateY(${item.start}px)` }}
>
<Row row={row}>{children}</Row>
</div>
)
})}
</div>
</RowsContext.Provider>
</ScrollViewport>
<ScrollBar orientation="vertical" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
export function Lane({ className, children, ...props }: ComponentProps<'div'>) {
return (
<div
data-trace-lane
className={cn('relative min-w-0 flex-1 overflow-hidden', className)}
{...props}
>
{children}
</div>
)
}
@@ -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<HTMLElement>) => void
onPointerMove: (event: PointerEvent<HTMLElement>) => void
onPointerUp: (event: PointerEvent<HTMLElement>) => void
onPointerCancel: (event: PointerEvent<HTMLElement>) => void
onDoubleClick: () => void
}
export interface BrushBodyProps extends Omit<BrushTrackProps, 'onDoubleClick'> {
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void
tabIndex: number
}
export interface BrushHandleProps extends Omit<BrushTrackProps, 'onDoubleClick'> {
onKeyDown: (event: KeyboardEvent<HTMLElement>) => 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<BrushMode>('idle')
const drag = useRef<DragState | null>(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<HTMLElement>, nextMode: BrushMode) => {
if (event.button !== 0) return
const track = event.currentTarget.closest<HTMLElement>('[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<HTMLElement>) => {
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<HTMLElement>) => {
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,
}
}
@@ -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<string, number>
selectedId: string | null
dispatch: Dispatch<ViewAction>
disabled?: boolean
}
export interface UseKeyboardResult {
onKeyDown: (event: KeyboardEvent<HTMLElement>) => 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<HTMLElement>) => {
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 }
}
@@ -0,0 +1,11 @@
import { createContext, use } from 'react'
import type { SpanContextValue } from '../types'
export const SpanContext = createContext<SpanContextValue | null>(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
}
@@ -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<T extends HTMLElement> {
ref: (element: T | null) => void
width: number
scale: TimeScale
}
export function useTimeScale<T extends HTMLElement = HTMLDivElement>(
options: UseTimeScaleOptions = {}
): UseTimeScaleResult<T> {
const { targetTickSpacing = 80 } = options
const { window, bounds } = useView()
const [element, setElement] = useState<T | null>(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 }
}
@@ -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<string, number>
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])
}
+49
View File
@@ -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'
+189
View File
@@ -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<string, unknown>
}
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<string, unknown>
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<string, Span>
childrenOf: ReadonlyMap<string, readonly string[]>
roots: readonly string[]
orphanIds: ReadonlySet<string>
depthOf: ReadonlyMap<string, number>
descendantCountOf: ReadonlyMap<string, number>
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<string>
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<string> }
| { 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<string>
query: string
matchIds: ReadonlySet<string> | null
dispatch: Dispatch<ViewAction>
}
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<string>
defaultQuery?: string
children?: ReactNode
}
@@ -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> = {}
): 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)
})
})
+780
View File
@@ -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<Span, 'startMs' | 'endMs'>, nowMs: number): number {
if (span.endMs === null) return Math.max(span.startMs, nowMs)
return span.endMs
}
export function durationOf(span: Pick<Span, 'startMs' | 'endMs'>, nowMs: number): number {
return Math.max(0, effectiveEndMs(span, nowMs) - span.startMs)
}
function compareByStart(byId: ReadonlyMap<string, Span>) {
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<string, Span>()
for (const span of spans) byId.set(span.id, span)
const childrenOf = new Map<string, string[]>()
const roots: string[] = []
const orphanIds = new Set<string>()
const services = new Set<string>()
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<string, number>()
const descendantCountOf = new Map<string, number>()
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<string>
): ReadonlySet<string> {
const keep = new Set<string>(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<string>,
matchIds: ReadonlySet<string> | 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<string, number> {
const map = new Map<string, number>()
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<TimeScaleOptions>): {
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<string> | null {
if (query.trim() === '') return null
const result = new Set<string>()
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<string, number>
}
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<string> = 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<MarkerKind, string> = {
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<string, unknown>): Array<[string, unknown]> {
return Object.entries(data).sort(([a], [b]) => a.localeCompare(b))
}
+3
View File
@@ -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