Trace
One request’s spans as a waterfall: each row a span, nested under its parent
by indentation, its bar placed along the trace’s length from where it started
to where it ended. The tree comes from SpanPrimitive, Pho’s own headless
span parts (after
@assistant-ui/react-o11y’s,
with no store or Radix behind them); the edge cases, the fold levels, the
search, and the zoom follow
Jaeger UI’s trace view. The
waterfall supplies the look, the time axis, selection, and the detail panel.
No fetching, and no clock of its own unless a span is still running.
- 62.4 ms
- 1.10 ms
- 0.60 ms
- 59.2 ms
query · db · codes.photon.spectrum-inbound
- 13.9 ms
- 6.00 ms
- 6.50 ms
- Span
- Error
- 7 spans
- 2 errors
- 62.4 ms total
Selected: query. Click a row — or press Escape — to close the panel. Drag across the axis to zoom; type in the search to find a span; click the card and press ] to fold everything.
Import
import { Trace } from "@photon-ai/pho-ui/components/trace";
Spans
spans is a flat list in the SpanData shape (the same one react-o11y
uses), with the derivable fields optional: a SpanData passes straight
through. A span with no parentSpanId, or one whose parent is not in the
list, is a root; the trace runs from the earliest start to the latest end,
and every bar is placed as a fraction of that. Siblings order by start.
type (http, db, llm, tool) and service show after the name as a
hint. status defaults to completed when the span has an endedAt and
running when it does not; failed inks the bar and the dot in the error
color, skipped draws a dashed outline.
const spans: TraceWaterfallSpan[] = [
{
id: "root",
name: "POST /v1/messages",
startedAt: 0,
endedAt: 62.4,
kind: "server",
attributes: { "http.response.status_code": 503 },
},
{
id: "pool",
parentSpanId: "root",
name: "pg-pool.connect",
startedAt: 1.8,
endedAt: 2.4,
attributes: { "db.system": "postgres" },
},
{
id: "send",
parentSpanId: "root",
name: "carrier.send",
startedAt: 48,
endedAt: 61.9,
kind: "client",
status: "failed",
},
];
<Trace.Waterfall spans={spans} selectedId={id} onSelect={setId} search />;
Bad data does not break the picture. A span whose parent the trace lacks
(one still being collected, say) becomes a root and the footer counts it as
a missing parent; a cycle is broken where it closes. A span with no usable
start (missing, NaN) inherits its parent’s instead of dragging the axis
back to the epoch; an end before its start, or not a number, collapses to
the start. Two spans with one id keep the later one.
Attributes
attributes is the span’s flat OpenTelemetry attribute map. The waterfall
reads a few of them the way Jaeger does. The namespace picks one icon for
the row: db. a database, http. the globe, rpc. the exchange,
messaging. a message. A GenAI span (gen_ai.operation.name: chat,
execute_tool, invoke_agent, embeddings) takes its kind’s icon instead,
so a row never carries two. Pills after the name print the facts worth
reading in the row: http.response.status_code (a 5xx in the error ink),
http.request.method, db.system, rpc.system, gen_ai.request.model.
A pill truncates past 8rem; its tooltip carries the full label and value.
kind is the OpenTelemetry span kind. A folded client span names the
server span it called (→ codes.photon.carrier-gateway) and draws that
span’s stretch on its own bar, so the fold hides nothing; a leaf client
or producer with peer.service names the callee the same way.
Folding
A parent folds its children on its chevron. A folded parent shows how many
it hides and, when failed spans are among them, a hollow error dot and
(3, 1 error), so an error never disappears into a fold. The header’s
fold controls open or fold one level at a time (the deepest open parents
first; the shallowest folded ones first) or everything at once. On the
keyboard: o / p for one level, [ / ] for all. defaultCollapsed
starts some folded; collapsed with onCollapsedChange owns it;
foldControls={false} hides the buttons.
- POST /v1/messagescodes.photon.api503POST62.4 ms
- auth.verifyKeycodes.photon.api1.10 ms
- pg-pool.connectcodes.photon.spectrum-inboundpostgres(1)0.60 ms
- carrier.sendcalls codes.photon.carrier-gatewaycodes.photon.sms, http503POST(2, 1 error)Failed.1 error hidden.13.9 ms
- webhook.delivercodes.photon.webhooks1.00 ms
- Span
- Error
- 8 spans
- 2 errors
- 1 missing parent
- 62.4 ms total
Selecting
With onSelect, each row is a button and the selected one (selectedId)
keeps the wash. A second click, or Escape, passes null so the host can
close it. renderSpanDetail expands that span’s facts as a full-width
panel under the row, inside the card, and receives the span exactly as it
was given.
Searching
search adds a toolbar band above the header with a ghost search box.
Terms are space-separated and any of them may match; "a phrase" keeps
its spaces; -key leaves an attribute out; key=value matches an
attribute exactly (http.response.status_code=503). A term matches the
name, the service, the type, the kind, the exact id, or an attribute’s key
or value. Matches take a wash, their folded ancestors open so every match
is in view, and the band counts them: Enter, the arrows, or f / b step
through, and the current match takes a ring. Pass query with
onQueryChange to own the text; / focuses the box.
Zooming
The time axis is the overview of the whole trace. Drag across it to zoom
to that stretch: the rest of the axis steps back, the range lights up
between two grips with its span in ms, and on release the bars glide into
place while the window keeps its place on the axis. Drag a grip to resize
it, drag anywhere to choose again; a chip in the header names the window (click it to come back). ↑ / ↓ zoom in and out around the middle, ← / → pan
(Shift for more); double-click the axis or press Escape to come back. defaultView starts zoomed ([0.7, 1] is the last
30%); view with onViewChange owns it.
- POST /v1/messagescodes.photon.api503POST62.4 ms
- auth.verifyKeycodes.photon.api1.10 ms
- pg-pool.connectcodes.photon.spectrum-inboundpostgres0.60 ms
- pg.query:SELECT postgrescalls pg-primarycodes.photon.spectrum-inbound, dbpostgres59.2 ms
- carrier.sendcodes.photon.sms, http503POSTFailed.13.9 ms
- POST /carrier/messagescodes.photon.carrier-gateway503Failed.6.00 ms
- carrier.send retrycalls twiliocodes.photon.sms6.50 ms
- Span
- Error
- 7 spans
- 2 errors
- 62.4 ms total
Keyboard
Shortcuts listen while focus is inside the card: click it, or tab to a
row. shortcuts="global" listens anywhere on the page outside a field,
for a page that is the trace; shortcuts={false} turns them off, and the
same actions are on useSpanTree() for a host that binds its own.
| Key | Action |
|---|---|
[ / ] |
Expand / collapse all |
o / p |
Expand / collapse one level |
↑ / ↓ |
Zoom in / out (Shift: more) |
← / → |
Pan (Shift: more) |
/ |
Focus the search |
f / b |
Next / previous match |
Enter / ⇧ Enter |
Next / previous match, from the search |
Esc |
Clear the search, then the zoom, then the selection |
Running spans
A span without an endedAt is still running: its bar is lighter, pulses,
and grows with the clock, and its duration reads as elapsed so far. The
waterfall ticks on its own while any span runs; pass now (a time on the
spans’ clock) to drive the redraw yourself, from the same tick that streams
the spans in.
<Trace.Waterfall spans={spans} now={now} />
Logs
Trace.Logs lists the log lines that belong to the trace, each at its
offset from the start with its level, in the mono face, under the same
header row a DataTable draws in its sm size: Offset, Level, Message.
Pass spans and a line’s spanId names the span it was written in, after
the message. With no lines it renders nothing at all: a trace with no logs
shows no empty block. loading paints bone rows under the header until
the lines arrive.
- +0.50 msInfoKey pho_sk_…9fD3 verified for project pho_prj_01kztbv929fa. auth.verifyKey
- +2.60 msDebugSELECT * FROM messages WHERE project_id = $1 ORDER BY created_at DESC LIMIT 20 pg.query:SELECT postgres
- +61.9 msErrorThe carrier answered 503 and the first retry did as well. carrier.send
<Trace.Logs
spans={spans}
logs={[
{ offsetMs: 0.5, level: "info", message: "Key verified.", spanId: "auth" },
{
offsetMs: 61.9,
level: "error",
message: "The carrier answered 503.",
spanId: "send",
},
]}
/>
Props
Trace.Waterfall
- spans —
TraceWaterfallSpan[]—id,name,startedAt, and optionallyparentSpanId,endedAt,latencyMs,status(completed/failed/running/skipped),kind(client/server/producer/consumer/internal),type,service,attributes - now —
number— the current time on the spans’ clock, for running spans. Omit and the waterfall keeps its own - selectedId / onSelect —
string | null/(id: string | null) => void— rows select whenonSelectis given; a re-click or Escape passesnull - renderSpanDetail —
(span: TraceWaterfallSpan) => ReactNode— a full-width panel under the selected row - defaultCollapsed / collapsed / onCollapsedChange — folded span ids
- foldControls —
boolean(defaulttrue) — the one-level / all buttons in the header, while there is a parent to fold - search —
boolean | string— a search box in a toolbar band, with its placeholder - query / onQueryChange —
string/(query: string) => void— own the search text - defaultView / view / onViewChange —
[start, end]fractions of the trace — the zoom window - shortcuts —
"focus" | "global" | false(default"focus") — where the keys listen - footer —
ReactNode— legend and totals. Default when there are spans; passnullto hide, or<Trace.Waterfall.Footer />to replace the slot - …plus any
<div>attribute
Trace.Waterfall.Footer
- …plus any
<div>attribute
Trace.Logs
- logs —
TraceLog[]—offsetMs,message,level?(debug/info/warn/error),spanId?,id? - spans —
{ id, name }[]— to name each line’s span - loading —
boolean | number— bone rows under the header,truefor five
Helpers: normalizeSpans(spans) (the SpanData list the tree is built
from), deriveSpanTree(spans), visibleSpans(tree, collapsed),
buildSpanTree(spans, collapsed?), traceRange(spans, now?),
filterSpans(query, spans), collapseOneLevel / expandOneLevel /
collapseAllIds, zoomView / panView / clampView / viewToRange,
spanPills(attributes) / spanDecorationIcon(attributes),
barGeometry(span, range, now?), spanDuration(span, now?),
axisTicks(startMs, endMs), formatDuration(ms), formatTick(ms),
niceStep(ms), footerTotals(facts).
SpanPrimitive
The waterfall is composed from SpanPrimitive, exported from the same
entry: headless, unstyled parts for a span tree, for a host that wants a
different look, a run inspector, or a timeline without the names. They
follow react-o11y’s part names, data attributes, and CSS variables, but are
plain React context with Base UI’s render prop in place of asChild.
- Provider —
spans: SpanData[],defaultCollapsed/collapsed/onCollapsedChange,now,query(withkeepFoldsOnMatch),defaultView/view/onViewChange. Derives the tree and the ranges and owns the fold, the matches, and the window. - Children —
(span: SpanItemState) => ReactNode, once per visible span. Inside it,useSpan()is the current span (depth,hasChildren,isCollapsed,descendants,descendantErrors,orphan,repaired,serverChildId, and the data) anduseSpanTree()the whole tree:spans(visible),all,byId,orphans,range,view,viewRange,zoomed,matches, and the actionstoggle,setCollapsed,expandAll/collapseAll/expandOne/collapseOne(withcanExpand/canCollapse),setView/zoom/pan/resetView. - Root — the row, with
data-span-id/-status/-type/-depth/-kind,data-collapsed,data-match,data-orphan. - Indent —
baseIndent+depth × indentPerLevelpx of left padding. - CollapseToggle — a button, only for a span with children; stops its click short of the row.
- StatusIndicator — a span with
data-span-status, anddata-descendant-errorswhile a fold hides failed spans. - TypeBadge, Name — a span each; they print the type and the name unless given children.
- Timeline — the track;
timeRange(default: the zoom window) andpaddingEnd; exposes--span-timeline-min-ms/-max-ms/-range-ms. - TimelineBar — the current span’s bar, absolutely positioned from
--span-timeline-left/-width(never under--span-timeline-min-width);nowfor a running span;data-span-runningwhile it runs;data-clipped-start/data-clipped-endwhere the window cuts it.
<SpanPrimitive.Provider spans={spans} query={query}>
<SpanPrimitive.Timeline render={<ol />}>
<SpanPrimitive.Children>
{() => (
<SpanPrimitive.Root render={<li />}>
<SpanPrimitive.Indent>
<SpanPrimitive.CollapseToggle>▸</SpanPrimitive.CollapseToggle>
<SpanPrimitive.Name />
</SpanPrimitive.Indent>
<div className="relative h-8">
<SpanPrimitive.TimelineBar className="top-1 h-6 rounded" />
</div>
</SpanPrimitive.Root>
)}
</SpanPrimitive.Children>
</SpanPrimitive.Timeline>
</SpanPrimitive.Provider>
Accessibility
- A list of spans in a region. The card is a
regionnamedTrace; the rows are an ordered list. Each name is read with its duration and, when it is not simply done, its status:Failed.,Running.,Skipped.. A fold hiding failures adds1 error hidden.; a callee reads ascalls codes.photon.carrier-gateway. - Two buttons per row. The chevron folds (
aria-expanded, namedCollapse pg-pool.connect); the name selects (aria-currenton the selected one) and stretches over the row so the bar is part of the target. WithrenderSpanDetail, the select button also setsaria-expandedandaria-controlsfor the panel under the row. - Controls have names. The fold controls are a
Foldgroup; the search is a labelled search box and its count is a live region; pills and GenAI icons carry the attribute and value. - Bars are decoration. The duration is text beside every bar; the axis and its zoom gesture are hidden from readers, and the keyboard reaches the same zoom and pan.
Best practices
- Feed it every span of one trace, on one clock; it takes care of the rest. Trim nothing: the fold is there for deep traces, and a missing parent is reported, not hidden.
- Pass
attributesas they came off the wire; the waterfall picks what to show. Putkindon spans so a fold can name its peer. - Streaming a live trace, keep the
spansarray’s identity stable between ticks unless a span changed, and pass the tick’s time asnow. - Use
shortcuts="global"only on a page that is the trace; on a page with other lists and fields, leave the default. - Put the trace’s
Logsblock right under the waterfall, and let it disappear when there is nothing to say. - Name spans the way the tracer does (
pg.query:SELECT postgres); the hint after it is for the service and the kind, not for prose.