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.mdstates it for the engine and the reasoning transfers unchanged: the bridge is the only component that knows what a span is. This package emits:telemetryevents, which are data, and stops there. - Every integration stays optional (ADR-0004).
:telemetryis already the family's lowest-common-denominator dependency and adds nothing a host did not have;opentelemetry_apiwould 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.
| Event | Measurements | Metadata |
|---|---|---|
[:statifier_ui, :subscriber, :attach] | buffered | session_id, path, projected, profile |
[:statifier_ui, :subscriber, :detach] | buffered, dropped | session_id, reason |
[:statifier_ui, :subscriber, :overflow] | dropped, capacity | session_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/:exceptiontriple, never a hand-rolled pair. The bridge getsdurationand thetelemetry_span_contextconvention for free, which is what lets it pair halves the way statifierdocs/opentelemetry.mdpairs macrostep halves - onspan_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 astatifier.macrostepspan are related by session id and by nothing else; the per-macrostep join is theotelkey in the wire format, and it is fed from the host rather than from this event surface.
Deep links: the consuming end of the otel key
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:
StatifierUI.Trace.DeepLinkcompiles a host-configured URL template and renders it against a message'sotelobject.StatifierUI.EventLog.DeepLinkis the seam a renderer calls: it asks the macrostep-shaped question ("what is this step's trace?"), because a macrostep is the unit upstream opens one span for and the unit an operator sees in the log.StatifierUI.EventLog.MarkdownandStatifierUI.Inspectorboth call it.
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/1where 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
otelkey on the stream, or ids that are not W3C Trace Context hex all returnnil- 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.