← ui

telemetry

lib

the lines and spans a log and a trace draw, linked both ways, without React

npx shadcn add @ag/telemetry
Already installed? Preview an update
npx shadcn add @ag/telemetry --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-13
  • Takes @adriangalilea/utils 10 (^10.0.0), the current major; nothing in it changed. The same for page-sensor, visits and stat: update the package in your project when you update them.
Updated 2026-10-13 · release notes

also called observability, events, log and trace

shelf data & status

uses clip

used by log, deployment, trace

the demo above, verbatim

telemetry.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import {
  isRaw,
  type LogLine,
  logEntries,
  readStream,
  spanTrees,
  type TraceSpan,
} from "@/registry/base-nova/lib/telemetry"
import { Log } from "@/registry/base-nova/ui/log"
import { Trace } from "@/registry/base-nova/ui/trace"

// #region request
const STARTED = Date.UTC(2026, 9, 12, 9, 41, 7)

/** Spans written in ms from the request's start, placed on the clock its lines share. */
const onClock = (spans: readonly TraceSpan[]): TraceSpan[] =>
  spans.map((s) => ({ ...s, from: STARTED + s.from, to: STARTED + s.to }))

/** One sign-up: its spans. */
const SPANS = onClock([
  {
    id: "req",
    name: "POST /signup",
    service: "web",
    from: 0,
    to: 268,
    status: "ok",
  },
  {
    id: "check",
    parentId: "req",
    name: "email.check",
    service: "auth",
    from: 6,
    to: 41,
    status: "ok",
  },
  {
    id: "insert",
    parentId: "req",
    name: "INSERT users",
    service: "postgres",
    from: 44,
    to: 97,
    status: "ok",
    attributes: { rows: 1 },
  },
  {
    id: "welcome",
    parentId: "req",
    name: "mail.welcome",
    service: "mail",
    from: 101,
    to: 262,
    status: "ok",
  },
  {
    id: "smtp",
    parentId: "welcome",
    name: "SMTP send",
    service: "mail",
    from: 110,
    to: 258,
    status: "ok",
    attributes: { relay: "smtp.eu", attempts: 2 },
  },
])

const NAMES = new Map(SPANS.map((s) => [s.id, s.name]))

/** What its services wrote, each line in the span it was written in: ms from the
 *  request's start, level, span, source, message. */
const LINES: LogLine[] = (
  [
    [0, "info", "req", "web", "POST /signup", { email: "ada@example.com" }],
    [9, "debug", "check", "auth", "address is deliverable"],
    [52, "debug", "insert", "postgres", "INSERT users: 1 row"],
    [112, "info", "smtp", "mail", "connecting to smtp.eu"],
    [180, "warn", "smtp", "mail", "relay busy; retrying", { attempt: 2 }],
    [258, "info", "smtp", "mail", "welcome mail accepted", { id: "m_51a" }],
    [268, "info", "req", "web", "POST /signup 201 in 268 ms"],
  ] as const
).map(([ms, level, span, source, message, fields], i) => ({
  id: `l${i}`,
  at: STARTED + ms,
  level,
  span: { id: span, name: NAMES.get(span) as string },
  source,
  message,
  fields,
}))
// #endregion

/** A trace and its log side by side, linked by the page: a span chosen marks its
 *  lines; a line chosen in the span's details is marked and brought into view in the
 *  log; a line's span chip chooses its span in the trace. */
function Linked() {
  const [span, setSpan] = React.useState<string | null>("smtp")
  const [line, setLine] = React.useState<string | null>(null)
  const spanOf = (id: string) => LINES.find((l) => l.id === id)?.span?.id
  return (
    <div className="grid gap-4 xl:grid-cols-[3fr_2fr]">
      <Trace
        spans={SPANS}
        label="POST /signup"
        visibleRows={6}
        selected={span}
        onSelectedChange={setSpan}
        lines={LINES}
        line={line}
        onLineChange={setLine}
      />
      <Log
        lines={LINES}
        label="POST /signup's log"
        filter={false}
        className="h-72 xl:h-auto"
        selected={line}
        onSelectedChange={(id) => {
          setLine(id)
          if (id) setSpan(spanOf(id) ?? null)
        }}
        span={span}
        onSpanChange={setSpan}
      />
    </div>
  )
}

// #region stream
/** A deploy as the logger wrote it (ts-utils' `log`, `LOG_FORMAT=json`): one event a
 *  line, the spans' starts and ends among them, and a line a runtime wrote past the
 *  logger. Read as a file, a tail or a platform's log drain would hand it over. */
const STREAM = String.raw`{"time":"2026-10-12T13:22:53.362Z","level":"info","verb":"wait","msg":"deploy ui","service":"vercel","scope":["deploy"],"phase":"start","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"1e32dac0460c4a43","fields":{"branch":"main","commit":"44911f6"}}
{"time":"2026-10-12T13:22:53.363Z","level":"info","verb":"info","msg":"cloning","service":"vercel","scope":["deploy"],"trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"1e32dac0460c4a43","fields":{"repo":"adriangalilea/ui"}}
{"time":"2026-10-12T13:22:53.485Z","level":"info","verb":"wait","msg":"install","service":"vercel","scope":["deploy"],"phase":"start","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"9b006fa59244eb83","parent_id":"1e32dac0460c4a43"}
{"time":"2026-10-12T13:22:53.485Z","level":"info","verb":"info","msg":"resolving","service":"vercel","scope":["deploy"],"trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"9b006fa59244eb83","fields":{"packages":812}}
{"time":"2026-10-12T13:22:53.826Z","level":"warn","verb":"warn","msg":"peer dependency unmet","service":"vercel","scope":["deploy"],"trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"9b006fa59244eb83","fields":{"package":"wordgard","wants":"react@^18"}}
{"time":"2026-10-12T13:22:53.918Z","level":"info","verb":"success","msg":"install","service":"vercel","scope":["deploy"],"phase":"end","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"9b006fa59244eb83","parent_id":"1e32dac0460c4a43","status":"ok","dur_ms":433.03,"fields":{"added":812,"cached":790}}
{"time":"2026-10-12T13:22:53.918Z","level":"info","verb":"wait","msg":"build","service":"vercel","scope":["deploy"],"phase":"start","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"89c2f3534fa1f1f3","parent_id":"1e32dac0460c4a43"}
{"time":"2026-10-12T13:22:53.918Z","level":"info","verb":"wait","msg":"compile","service":"vercel","scope":["deploy"],"phase":"start","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"8f41747b20a22cc9","parent_id":"89c2f3534fa1f1f3"}
{"time":"2026-10-12T13:22:54.329Z","level":"info","verb":"success","msg":"compiled","service":"vercel","scope":["deploy"],"trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"8f41747b20a22cc9","fields":{"files":1394}}
{"time":"2026-10-12T13:22:54.329Z","level":"info","verb":"success","msg":"compile","service":"vercel","scope":["deploy"],"phase":"end","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"8f41747b20a22cc9","parent_id":"89c2f3534fa1f1f3","status":"ok","dur_ms":410.813}
{"time":"2026-10-12T13:22:54.329Z","level":"info","verb":"wait","msg":"static pages","service":"vercel","scope":["deploy"],"phase":"start","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"ea73d9308640e131","parent_id":"89c2f3534fa1f1f3","fields":{"pages":162}}
{"time":"2026-10-12T13:22:54.559Z","level":"info","verb":"info","msg":"generated","service":"vercel","scope":["deploy"],"trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"ea73d9308640e131","fields":{"done":162}}
{"time":"2026-10-12T13:22:54.560Z","level":"info","verb":"success","msg":"static pages","service":"vercel","scope":["deploy"],"phase":"end","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"ea73d9308640e131","parent_id":"89c2f3534fa1f1f3","status":"ok","dur_ms":230.786,"fields":{"pages":162}}
{"time":"2026-10-12T13:22:54.560Z","level":"info","verb":"success","msg":"build","service":"vercel","scope":["deploy"],"phase":"end","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"89c2f3534fa1f1f3","parent_id":"1e32dac0460c4a43","status":"ok","dur_ms":641.768}
{"time":"2026-10-12T13:22:54.560Z","level":"info","verb":"wait","msg":"upload","service":"vercel","scope":["deploy"],"phase":"start","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"63f4bd4512fcc89f","parent_id":"1e32dac0460c4a43"}
{"time":"2026-10-12T13:22:54.560Z","level":"info","verb":"wait","msg":"put assets","service":"vercel","scope":["deploy"],"phase":"start","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"6352c0960a124dc1","parent_id":"63f4bd4512fcc89f"}
{"time":"2026-10-12T13:22:54.701Z","level":"error","verb":"fail","msg":"put assets","service":"vercel","scope":["deploy"],"phase":"end","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"6352c0960a124dc1","parent_id":"63f4bd4512fcc89f","status":"error","dur_ms":141.149,"fields":{"err":{"type":"Error","message":"upload timed out after 120 ms","stack":"Error: upload timed out after 120 ms\n    at put (file:///vercel/path0/scripts/upload.mjs:31:11)\n    at async upload (file:///vercel/path0/scripts/upload.mjs:29:4)"}}}
{"time":"2026-10-12T13:22:54.701Z","level":"warn","verb":"warn","msg":"retrying upload","service":"vercel","scope":["deploy"],"trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"63f4bd4512fcc89f","fields":{"attempt":2}}
{"time":"2026-10-12T13:22:54.701Z","level":"info","verb":"wait","msg":"put assets","service":"vercel","scope":["deploy"],"phase":"start","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"31cc262a7651c027","parent_id":"63f4bd4512fcc89f","fields":{"attempt":2}}
{"time":"2026-10-12T13:22:54.882Z","level":"info","verb":"success","msg":"put assets","service":"vercel","scope":["deploy"],"phase":"end","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"31cc262a7651c027","parent_id":"63f4bd4512fcc89f","status":"ok","dur_ms":181.179,"fields":{"attempt":2}}
{"time":"2026-10-12T13:22:54.882Z","level":"info","verb":"success","msg":"upload","service":"vercel","scope":["deploy"],"phase":"end","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"63f4bd4512fcc89f","parent_id":"1e32dac0460c4a43","status":"ok","dur_ms":322.73}
{"time":"2026-10-12T13:22:54.882Z","level":"info","verb":"ready","msg":"deployed","service":"vercel","scope":["deploy"],"trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"1e32dac0460c4a43","fields":{"url":"https://ui.adriangalilea.com"}}
{"time":"2026-10-12T13:22:54.882Z","level":"info","verb":"success","msg":"deploy ui","service":"vercel","scope":["deploy"],"phase":"end","trace_id":"0aacabc7781cdf2f239c91369bd610ae","span_id":"1e32dac0460c4a43","status":"ok","dur_ms":1520.287,"fields":{"branch":"main","commit":"44911f6"}}
(node:4242) ExperimentalWarning: VM Modules is an experimental feature
{"time":"2026-10-12T13:22:54.882Z","level":"info","verb":"success","msg":"done","service":"vercel"}`
// #endregion

/** The stream read once (lib/telemetry): the log's lines and the trace's spans from
 *  the same events, so they link both ways with nothing written twice. */
function FromStream() {
  const { lines, trace } = React.useMemo(() => {
    const entries = readStream(STREAM)
    return { lines: logEntries(entries), trace: spanTrees(entries)[0] }
  }, [])
  if (!trace) throw new Error("telemetry demo: the stream holds no span")
  const [span, setSpan] = React.useState<string | null>(null)
  const [line, setLine] = React.useState<string | null>(null)
  const spanOf = (id: string) => {
    const l = lines.find((x) => x.id === id)
    return l && !isRaw(l) ? l.span?.id : undefined
  }
  return (
    <div className="grid gap-4">
      <Trace
        spans={trace.spans}
        label="deploy ui"
        visibleRows={8}
        selected={span}
        onSelectedChange={setSpan}
        lines={lines}
        line={line}
        onLineChange={setLine}
      />
      <Log
        lines={lines}
        label="deploy ui's log"
        className="h-80"
        selected={line}
        onSelectedChange={(id) => {
          setLine(id)
          if (id) setSpan(spanOf(id) ?? null)
        }}
        span={span}
        onSpanChange={setSpan}
      />
    </div>
  )
}

export default function TelemetryDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="linked"
        title="a trace and its log, linked"
        description="one request: choose a span and its lines are marked in the log; choose a line in the span's details and the log goes to it; a line's span chip chooses its span."
        with="request"
        glance="large"
      >
        <div className="p-4">
          <Linked />
        </div>
      </Sample>
      <Sample
        name="stream"
        title="read from the stream"
        description="what the logger wrote, as it wrote it: readStream, then the log's lines (logEntries) and the trace (spanTrees) from the same events. The failed upload and its retry, the warning inside install, and the runtime's line that is no event, kept where it fell."
        with="stream"
      >
        <div className="p-4">
          <FromStream />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

telemetry.ts
// TELEMETRY, as the log and the trace draw it: lines and spans, the two views of one
// stream of events. Framework-free.
//
// A program writes one stream (the cross-language contract go-utils, ts-utils and
// py-utils keep, github.com/adriangalilea/utils): a log line is an event at an
// instant, a span is a piece of work written as a start and an end, and every line
// written inside a span names it. This module holds what the views take (`LogLine`,
// `RawLine`, `TraceSpan`) and the reading of the stream into them, so a log and a
// trace drawn from one stream link both ways: a line names its span, a span holds
// its lines.
//
//   instants   ms since the epoch, the line's `at` and the span's `from` and `to`
//              alike, so a line falls on its span's bar where it happened
//   ids        a line's `id` is unique in its log; a span's `id` unique in its trace

import { type LogEvent, parseEvent } from "@adriangalilea/utils/log"
import type { Span } from "@/registry/base-nova/lib/clip"

/** The levels, quietest first: what a log's floor filters by. */
export const LOG_LEVELS = ["trace", "debug", "info", "warn", "error"] as const
export type LogLevel = (typeof LOG_LEVELS)[number]

/** What the writer said happened: each verb writes at one level (the contract's
 *  table), and the log marks the outcomes (✓ success, ⨯ fail, ○ wait, ▶ ready, •
 *  step). */
export type LogVerb =
  | "error"
  | "fail"
  | "warn"
  | "info"
  | "success"
  | "wait"
  | "ready"
  | "step"
  | "debug"
  | "trace"

/** A field's value: JSON, nested as the writer gave it. */
export type FieldValue =
  | string
  | number
  | boolean
  | null
  | readonly FieldValue[]
  | { readonly [key: string]: FieldValue }

export type Fields = Readonly<Record<string, FieldValue>>

/** One line of a log. */
export interface LogLine {
  /** Unique in the log: what keeps a line's height, place, opened and chosen state. */
  id: string
  /** When it was written, ms since the epoch. */
  at: number
  level: LogLevel
  /** The verb that wrote it, when it says more than its level (`success`, `fail`). */
  verb?: LogVerb
  /** What wrote it: a scope path, a service, a host ("payments", "api · node-07"). */
  source?: string
  message: string
  /** Structured fields, shown under the line when it is opened. */
  fields?: Fields
  /** The span it was written in: what links the line to its trace. */
  span?: { id: string; name: string }
  /** A span's own line: its start, or its end (with `took`). */
  phase?: "start" | "end"
  /** An ended span's length, ms. */
  took?: number
}

/** A line of a log file that is no event: a runtime's panic, a library writing to
 *  stderr itself. Kept where it fell, as written, whatever the level shown. */
export interface RawLine {
  id: string
  raw: string
}

export type LogEntry = LogLine | RawLine

export const isRaw = (entry: LogEntry): entry is RawLine => "raw" in entry

/** How a span ended, or that it has not: a `running` span is one whose end was never
 *  written (still at work, or its process died in it), drawn to the last instant the
 *  stream knows. */
export type TraceStatus = "ok" | "error" | "running"

/** One span of a trace: a unit of work, the span of time it took (`from`, `to`, ms on
 *  any epoch, every span of a trace and its lines on the same one) and what it was. */
export interface TraceSpan extends Span {
  id: string
  /** The span that called it; absent on the root, and only there. */
  parentId?: string
  /** What it did: an operation, a query, a call ("GET /cart", "SELECT cart_items"). */
  name: string
  /** What ran it: its colour on the waterfall. */
  service: string
  status: TraceStatus
  attributes?: Fields
  /** Why it may run outside its parent's span: the parent does not wait for it (a
   *  queued job, a fire-and-forget write), or two hosts' clocks disagree. Without it,
   *  a child outside its parent is a malformed trace, and the trace says so. */
  detached?: string
}

/** A field's value as one line of text: a string as itself, anything else as JSON. */
export function fieldText(value: FieldValue): string {
  return typeof value === "string" ? value : JSON.stringify(value)
}

// ── Reading the stream ───────────────────────────────────────────────────────────
//
// The text a program wrote (a file, a tail, a platform's log drain), one event per
// line, parsed by ts-utils' `parseEvent`, the contract's own reader: an event that
// breaks the schema throws there, a line that is no event is kept as written.

/** One line of a stream, by its place in it (from 1): an event, or a line that is no
 *  event. */
export type StreamEntry =
  | { n: number; event: LogEvent }
  | { n: number; raw: string }

/** Every line of `text` that holds anything, in order. */
export function readStream(text: string): StreamEntry[] {
  const out: StreamEntry[] = []
  text.split("\n").forEach((line, i) => {
    if (line.trim() === "") return
    const event = parseEvent(line)
    out.push(event ? { n: i + 1, event } : { n: i + 1, raw: line })
  })
  return out
}

/** What a span's id names, where the stream says: its name from its start, or the
 *  head of its id for a span another process wrote. */
function spanNames(entries: readonly StreamEntry[]): Map<string, string> {
  const names = new Map<string, string>()
  for (const e of entries)
    if ("event" in e && e.event.phase === "start")
      names.set(e.event.span_id, e.event.msg)
  return names
}

/** The stream as a log's lines. A line's source is its scope path, after its service
 *  when the stream holds more than one ("checkout · payments"); a span's own events
 *  are its lines too, the end with its length. Ids are the lines' places in the
 *  stream. */
export function logEntries(entries: readonly StreamEntry[]): LogEntry[] {
  const names = spanNames(entries)
  const services = new Set<string>()
  for (const e of entries) if ("event" in e) services.add(e.event.service)
  const many = services.size > 1
  return entries.map((e): LogEntry => {
    if ("raw" in e) return { id: String(e.n), raw: e.raw }
    const ev = e.event
    const scope = ev.scope?.join(" ")
    const source =
      [many ? ev.service : undefined, scope].filter(Boolean).join(" · ") ||
      undefined
    const at = Date.parse(ev.time)
    if (!Number.isFinite(at))
      throw new Error(`telemetry: line ${e.n} has no instant (${ev.time})`)
    return {
      id: String(e.n),
      at,
      level: ev.level,
      verb: ev.verb,
      source,
      message: ev.msg,
      fields: ev.fields,
      span: ev.span_id
        ? {
            id: ev.span_id,
            name: names.get(ev.span_id) ?? ev.span_id.slice(0, 8),
          }
        : undefined,
      phase: ev.phase,
      took: ev.phase === "end" ? ev.dur_ms : undefined,
    }
  })
}

/** A trace as the stream holds it: a span with no parent in the stream, and every
 *  span under it. A process that joined a remote trace roots there; a stream that
 *  merges both processes holds the remote span, and one tree. */
export interface SpanTree {
  traceId: string
  root: TraceSpan
  spans: TraceSpan[]
}

/** Times are written to the millisecond, and a span's length from a monotonic clock:
 *  a child can land this far past its parent's ends by rounding alone, and is held
 *  inside them. */
const ROUNDING_MS = 1

/** The stream's spans as trees, oldest root first. A span is its start and end
 *  (`to` from its start and its length, which the monotonic clock measured); one
 *  with no end is `running` to the last instant the stream holds of its trace. A
 *  child past its parent's ends by more than rounding is `detached`, saying why: a
 *  child another service wrote keeps its own clock; a child of the same service that
 *  ends after its parent was not waited for (work handed off past a reply), or its
 *  end never reached the stream. One of the same service that starts before its
 *  parent is a malformed stream, and the trace says so. */
export function spanTrees(entries: readonly StreamEntry[]): SpanTree[] {
  /** The last instant the stream holds of each trace. */
  const last = new Map<string, number>()
  const ends = new Map<string, LogEvent & { phase: "end" }>()
  const starts: (LogEvent & { phase: "start" })[] = []
  for (const e of entries) {
    if (!("event" in e)) continue
    const ev = e.event
    if (ev.trace_id)
      last.set(
        ev.trace_id,
        Math.max(
          last.get(ev.trace_id) ?? Number.NEGATIVE_INFINITY,
          Date.parse(ev.time),
        ),
      )
    if (ev.phase === "start") starts.push(ev)
    else if (ev.phase === "end") ends.set(ev.span_id, ev)
  }
  const spans = new Map<string, TraceSpan>()
  for (const s of starts) {
    const end = ends.get(s.span_id)
    const from = Date.parse(s.time)
    spans.set(s.span_id, {
      id: s.span_id,
      parentId: s.parent_id,
      name: s.msg,
      service: s.service,
      from,
      to: end
        ? from + end.dur_ms
        : Math.max(from, last.get(s.trace_id) as number),
      status: end ? end.status : "running",
      attributes: end?.fields ?? s.fields,
    })
  }
  const trace = new Map<string, string>()
  for (const s of starts) trace.set(s.span_id, s.trace_id)
  const children = new Map<string, TraceSpan[]>()
  const roots: TraceSpan[] = []
  for (const span of spans.values()) {
    const parent = span.parentId ? spans.get(span.parentId) : undefined
    if (!parent) {
      roots.push({ ...span, parentId: undefined })
      continue
    }
    const list = children.get(parent.id) ?? []
    list.push(span)
    children.set(parent.id, list)
  }
  const tree = (root: TraceSpan): TraceSpan[] => {
    const out: TraceSpan[] = []
    const walk = (s: TraceSpan, parent: TraceSpan | undefined) => {
      let held = s
      if (parent) {
        const early = parent.from - s.from
        const late = s.to - parent.to
        if (early > ROUNDING_MS || late > ROUNDING_MS)
          held =
            s.service !== parent.service
              ? {
                  ...s,
                  detached: `${s.service}'s clock is its own, not ${parent.service}'s`,
                }
              : early > ROUNDING_MS
                ? s
                : {
                    ...s,
                    detached:
                      s.status === "running"
                        ? `${parent.name} ended first: it was not waited for, or its end never reached the stream`
                        : `${parent.name} ended before it: it was not waited for`,
                  }
        else
          held = {
            ...s,
            from: Math.max(s.from, parent.from),
            to: Math.min(s.to, parent.to),
          }
      }
      out.push(held)
      for (const c of children.get(s.id) ?? []) walk(c, held)
    }
    walk(root, undefined)
    return out
  }
  return roots
    .toSorted((a, b) => a.from - b.from)
    .map((root) => ({
      traceId: trace.get(root.id) as string,
      root,
      spans: tree(root),
    }))
}