Pho Design System

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.

Fold
  1. 62.4 ms
  2. 1.10 ms
  3. 0.60 ms
  4. 59.2 ms

    query · db · codes.photon.spectrum-inbound

  5. 13.9 ms
  6. 6.00 ms
  7. 6.50 ms
  • 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.

Fold
  1. POST /v1/messagescodes.photon.api503POST
    62.4 ms
  2. auth.verifyKeycodes.photon.api
    1.10 ms
  3. pg-pool.connectcodes.photon.spectrum-inboundpostgres(1)
    0.60 ms
  4. carrier.sendcalls codes.photon.carrier-gatewaycodes.photon.sms, http503POST(2, 1 error)Failed.1 error hidden.
    13.9 ms
  5. webhook.delivercodes.photon.webhooks
    1.00 ms
  • 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.

  1. POST /v1/messagescodes.photon.api503POST
    62.4 ms
  2. auth.verifyKeycodes.photon.api
    1.10 ms
  3. pg-pool.connectcodes.photon.spectrum-inboundpostgres
    0.60 ms
  4. pg.query:SELECT postgrescalls pg-primarycodes.photon.spectrum-inbound, dbpostgres
    59.2 ms
  5. carrier.sendcodes.photon.sms, http503POSTFailed.
    13.9 ms
  6. POST /carrier/messagescodes.photon.carrier-gateway503Failed.
    6.00 ms
  7. carrier.send retrycalls twiliocodes.photon.sms
    6.50 ms
  • 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.

OffsetLevelMessage
  1. +0.50 msInfoKey pho_sk_…9fD3 verified for project pho_prj_01kztbv929fa. auth.verifyKey
  2. +2.60 msDebugSELECT * FROM messages WHERE project_id = $1 ORDER BY created_at DESC LIMIT 20 pg.query:SELECT postgres
  3. +61.9 msErrorThe carrier answered 503 and the first retry did as well. carrier.send
OffsetLevelMessage
<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

Trace.Waterfall.Footer

Trace.Logs

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.

<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

Best practices