Telemetry and the OTel bridge half

Copy Markdown View Source

What this package emits about its own work, and how the family-scoped bridge consumes it. This is the sui-b94 design note; the wire-format half of the same bead - whether and how a trace message carries OTel correlation ids - is ADR-0013, and this note does not restate it.

Read alongside statifier's docs/opentelemetry.md (the span topology the bridge implements for the engine) and statifier ADR-0062 (the bridge is a separate package, opentelemetry_statifier, scoped to the whole family rather than to the engine alone).

Status: accepted (2026-09-01, campaign-025; unqualified direction-agent verdict). Nothing here is emitted today. The surface below is the decision about what this package will emit and what it will never emit; the emit sites and their tests are a follow-up bead.

Constraints fixed before this note

  • No OTel API call from this repository, ever. statifier docs/opentelemetry.md states it for the engine and the reasoning transfers unchanged: the bridge is the only component that knows what a span is. This package emits :telemetry events, which are data, and stops there.
  • Every integration stays optional (ADR-0004). :telemetry is already the family's lowest-common-denominator dependency and adds nothing a host did not have; opentelemetry_api would be a dependency this package cannot make optional in any honest sense, and is not this package's call to add to a host.
  • This is not an adoption of statifier ADR-0040. ADR-0002 considered the engine's session telemetry contract for its adopted list and declined it, and that stands. This package emits its own events under its own namespace; it subscribes to none of the engine's. The two surfaces meet in the bridge and nowhere else.
  • ADR-0012's projection discipline applies to metadata. A telemetry metadata key is a value position that no projection profile can reach, so the rule here is stricter than the wire format's: chart values and datamodel values never appear in an event at all, not even redacted.

What emits, and what deliberately does not

The rule that decides membership: a pure module emits nothing. Most of lib/ is pure by design - StatifierUI.Inspector, StatifierUI.EventLog, StatifierUI.DatamodelExplorer, StatifierUI.TruthTable, StatifierUI.Value, StatifierUI.Shape and the Trace.* pure modules are folds over data a caller already holds. Instrumenting a fold reports the caller's loop, not this package's work, and a host that wants that timing has its own span around the call. Their absence from the list below is a decision, not an oversight.

That leaves three places where this package does something a host cannot see from outside: the one process it owns, the one place it reads a file, and the one place it does non-trivial work over a whole chart.

[:statifier_ui, :subscriber, ...] - the attach lifecycle

StatifierUI.Trace.Subscriber is the only GenServer here. It owns attach and detach, seq stamping, and the bounded buffer, and it is where a host's "why is my inspector missing the opening burst?" question is actually answerable.

EventMeasurementsMetadata
[:statifier_ui, :subscriber, :attach]bufferedsession_id, path, projected, profile
[:statifier_ui, :subscriber, :detach]buffered, droppedsession_id, reason
[:statifier_ui, :subscriber, :overflow]dropped, capacitysession_id

path is :early | :late | :catch_up - the three attach sequences the subscriber's own moduledoc distinguishes, and the distinction that decides whether the initialize burst is in the stream at all (st-uqo4, statifier ADR-0049 for the catch-up path). It is the single most useful field in this whole surface: an inspector that looks wrong because it attached late looks identical to one that looks wrong for a real reason.

projected and profile name the ADR-0012 projection in force, so a capture filed by an operator can be told apart from a full one without reading the stream.

:overflow is not the drop notification StatifierUI.Trace.Buffer refuses. That moduledoc rejects a synthetic drop message, on the ground that it would consume a seq and corrupt the counter a consumer detects loss by. A telemetry event is out of band: it consumes no seq, it never enters the buffer, and the wire stream is byte-identical whether or not anyone is listening. It is emitted on the transition into a dropping state and not once per dropped message, which is the difference between an alert and a flood.

[:statifier_ui, :fixtures, :load, ...] - a span, because it is IO

StatifierUI.Fixtures.Sidecar reads a <chart>.fixtures.json file (ADR-0003, extended by ADR-0006) and decodes it; StatifierUI.Fixtures.Lint walks the result. It can fail, it touches a filesystem, and it is the step a host blames when a notebook opens with no fixtures.

Emitted as a :telemetry.span/3 triple - :start, :stop, :exception - with the standard duration on the stop half.

Metadata: path (the sidecar's own path, which is developer-supplied configuration and not run data), version (the bundle's), the four counts scenarios / events / datasets / expressions, diagnostics (how many, not which), and lint_findings (likewise). On the :stop half, outcome is :ok or :error with reason naming the error tag only - :duration_in_scenario, :unknown_key, and so on.

The counts and not the contents: a fixture bundle is a datamodel bundle by construction (ADR-0012 replaces it whole rather than descending into it), so nothing under scenarios, events, datasets, or an expression's expect may appear in an attribute. An expression's source is chart- adjacent text and is likewise out - the count is the signal a host needs, and the source is a thing they can read from the file they already have.

[:statifier_ui, :diagram, :render, ...] - a span, server half only

StatifierUI.Diagram.render/2 is pure, which by the rule above would exclude it, and it is included anyway for one reason: it is O(chart) on every configuration change, so it is the only pure function here whose cost a host can actually notice. Emitted as a span, with states, transitions, and lifted_edges as measurements and backend as metadata.

lifted_edges counts the cross-hierarchy transitions Mermaid cannot draw and render/2 lifts to the endpoints' least common ancestor. It is a diagram-quality signal rather than a performance one: a chart with many lifted edges is being drawn misleadingly, and today nothing says so.

backend is :mermaid today and will be :elk when ADR-0008's destination renderer arrives, which is exactly the kind of change a metadata key absorbs and a hard-coded event name does not.

The browser half is deliberately absent. ADR-0008 puts layout in client-side elkjs and ADR-0009 ships the JavaScript as source, so the expensive part of rendering happens somewhere the BEAM cannot emit from. Instrumenting it would mean a browser tracing SDK inside a package whose JavaScript a host compiles with its own esbuild - a dependency decision belonging to the host, not to a component library. A host that wants browser spans instruments its own page around the hook. Server-side, this event covers graph construction and nothing else, and should be read that way rather than as the render's total cost.

Naming and shape rules

  • Namespace [:statifier_ui, <component>, <operation>], matching the engine's [:statifier, :session, ...] shape so a bridge handler list reads uniformly across the family.
  • Spans use :telemetry.span/3's :start/:stop/:exception triple, never a hand-rolled pair. The bridge gets duration and the telemetry_span_context convention for free, which is what lets it pair halves the way statifier docs/opentelemetry.md pairs macrostep halves - on span_ref, never on reconstructed identity.
  • Point events carry no span, and there are three of them (attach, detach, overflow). They become span events on whatever span the host has open, or standalone zero-duration spans if it has none - the bridge's call, not this note's.
  • Measurements are numbers; metadata is identity. No struct, no configuration map, and no decoded fixture bundle rides in either.
  • Nothing unbounded. Every metadata value above is a fixed atom, an integer, a session id, or a developer-supplied path. There is no key whose cardinality grows with the run.

The bridge half

opentelemetry_statifier is family-scoped by statifier ADR-0062 - siblings' telemetry surfaces land there as optional per-library setup calls rather than each package growing its own bridge. This surface is one of those siblings, and the bridge-side handler, its module name, and its setup function are that package's to name in that package's repository, under the cross-repo rule this repo records in ADR-0010 and its CLAUDE.md. This note supplies the contract to bridge against and stops there.

Two things the bridge should know about this surface specifically:

  • It is not on the engine's critical path. These events fire when a host attaches an inspector, loads fixtures, or draws a diagram - not per macrostep and not per microstep. Whatever sampling the macrostep spans need, this surface does not need it.
  • The correlation between the two surfaces runs through ADR-0013, not through here. A [:statifier_ui, :subscriber, :attach] span and a statifier.macrostep span are related by session id and by nothing else; the per-macrostep join is the otel key in the wire format, and it is fed from the host rather than from this event surface.

ADR-0013's key exists so an operator can move between this package's rendering of a run and the APM backend's rendering of the same run. The producer half stamps it (StatifierUI.Trace.Otel); the consuming half turns it into a URL, and lives in two modules:

Both renderers read the same option, so a host configures it once per render call rather than assembling links itself:

template = "https://apm.example.com/trace/{trace_id}?span={span_id}"

StatifierUI.Inspector.event_log(messages, deep_link: template)
StatifierUI.Inspector.selection_note(messages,
  selection: {:macrostep, 2},
  deep_link: template
)

The event log appends - [trace](...) to the summary line of every macrostep that carries correlation, after the - shown in the diagram marker when both apply; selection_note/2 appends [open trace](...) for the macrostep on screen, leaving the quiescent and carried-forward wording exactly as it was. Building the links directly, without a renderer, is for_log/2:

links = StatifierUI.EventLog.DeepLink.for_log(log, StatifierUI.EventLog.DeepLink.from_opts(deep_link: template))

The template is host configuration and has nothing else it could be. This package does not know which backend the host's spans went to, and a default would be a guess that sends an operator to a URL that does not exist. The variables are {trace_id}, {span_id}, {session}, and {macrostep}; substituted values are percent-encoded to the unreserved set, so a variable is safe in a path segment or a query value.

Two failure modes are separated deliberately, and the split is the whole design:

  • A bad template is loud, at configuration time. An unknown variable, a stray brace, or a template naming neither id (which would render one URL for every step) raises from from_opts/1 where the option is read. A typo that silently produced plausible URLs resolving nowhere is the outcome this refuses.
  • A step with no trace is silent, at render time. No configured template, no otel key on the stream, or ids that are not W3C Trace Context hex all return nil - no link, no error, no log line. Absence is the documented normal case: the key is omitted whenever no bridge is attached, so most streams carry none, and a renderer asks for a link unconditionally rather than first working out the stream's provenance.

Malformed ids fold into "no link" rather than into an error because the wire format is language-neutral (ADR-0005): an otel object may come from a producer this repository never saw, and ids that cannot be looked up in any backend are worse to link to than nothing.

Degradation

:telemetry.execute/3 with no attached handlers is a lookup and a return. There is no configuration knob, no enabled: option, and no compile-time switch: a host that attaches no handler pays effectively nothing, and one that attaches the bridge gets everything above. Adding a knob would create a second thing to get wrong and a support question ("is telemetry on?") that currently has no way to exist.

What this note does not do

  • It does not add a dependency or a line of emitting code. The emit sites are a follow-up bead in this repository. (The deep-link section above describes modules that exist; the telemetry surface itself still does not.)
  • It does not decide the OTel span shape these events map to. That is the bridge's, in its own repository.
  • It does not touch the wire format. Correlation ids are ADR-0013; this surface is about this package's own work and is invisible in the stream.
  • It does not instrument the pure fold modules, and a later bead that wants to should argue with the "a pure module emits nothing" rule above rather than quietly adding one.