This note is the design record for what statifier_persistence emits and
what it deliberately leaves to others. It was written as a specification
ahead of the code; the family-two emit sites landed in sp-m0i and the
family-one ones in sp-t01, so both tables below now describe what this
package emits rather than what it promises to. Nothing in the contract
changed on the way in -
docs/adr/0009-telemetry-events-for-the-durable-stepper.md froze it, and
decision 8's amendment discipline is how it moves.
One row is specification still, and the table says so where it sits:
[:statifier, :session, :unroutable] has no emit site, because no seam in
this package can currently produce the case it names.
Four records govern and are not restated here:
st-ADR-0062andstatifier-ex'sdocs/opentelemetry.md- the family's span topology and the ruling that the OpenTelemetry bridge is one separate package,opentelemetry_statifier, consuming public:telemetryevents only. Its "What lands where" table gives this repository two rows, and both are this note's mandate.st-ADR-0067- one telemetry contract across stepping drivers. Its decision 2 puts the emitters inStatifier.Telemetry; decision 3 tabulates what a process-less driver emits; decision 4 adds thedrivermetadata key; decision 6 says the storage phases are this package's own surface, under[:statifier_persistence, ...].st-ADR-0040(as amended byst-ADR-0067) andStatifier.Telemetry- the family's event conventions, adopted here in full except where this note records a deliberate departure.sob-ADR-0006andstatifier_oban'sdocs/telemetry.md- the sibling that went first, whose precedents this note adopts and, twice, deliberately departs from.
Two families, and why this package has two
Every other sibling in the family emits one event family of its own. This package emits that, and it also emits the interpreter's family, because it is a stepping driver.
Family one: [:statifier, :session, ...], emitted through
Statifier.Telemetry with driver: :persistence. An execution stepped
here produces the same 27-name contract an execution hosted in a
Statifier.Session does - the same macrostep spans, the same effect events,
the same counters - because this package calls the same functions session.ex
calls rather than reimplementing a documented table. This is the whole
point of st-ADR-0067: a backend user should not have to know that a
durably-stepped macrostep is a different kind of thing, because it is not.
The one difference is the statifier.driver attribute.
Family two: [:statifier_persistence, ...], this package's own.
Everything about how a position got into memory and back out: the
serialized unit, the lock, the adapter calls, the identity guard, the
execution record's lifecycle, the executor seam's failures, and the
durable-subchart seam. st-ADR-0067 decision 6 draws that line and names this namespace.
The two nest. A durable macrostep span appears inside the step span this
package opens around it, because the bridge parents it from its own
pid-keyed span table (ots-ADR-0004 decision 4) rather than from the
process's ambient OTel context, which it never reads; both are emitted in
the same process during the same synchronous call, which is what puts them
in one row's reach.
Family one: what this package emits as a driver
Applicability is st-ADR-0067 decision 3's table. What that means here,
concretely, at the emit sites:
| Event | Emitted by this package? | Where |
|---|---|---|
[:statifier, :session, :init] | yes, exactly once per logical execution | Executions.create/4, around Interpreter.initialize/2, resumed: false, invoked_by: nil. Never on a load - every Executions.step/5 is a rehydration, and an :init per load would fire thousands of times per execution |
[..., :halt] | yes | the step whose outcome is terminal, once its write has landed. reason is :done or :budget_exhausted; fail/4 and cancel/3 reach no interpreter and emit none |
[..., :terminate] | never | it names a GenServer callback; there is no process here, and both halves of every span arrive inside one call, so there is no open-span entry to leak |
[..., :macrostep, :start] / [..., :stop] | yes | brackets each Interpreter advance call: initialize/2 in Executions.create/4 (trigger: :initialize), handle_event/2 in Executions.stepped/7 (trigger: :event), and each deliver_internal/5 re-entry wave (trigger: :internal, nested, per st-ADR-0067 decision 5) |
[..., :interpret] | never | this package has no st-ADR-0029 injection seam. If it grows one it emits this event rather than minting a name |
[..., :unroutable] | contract only - no emit site today | it names an effect nothing could route: no dispatch arm, and no executor accepted its kind. The executor seam's only verdicts are :ok and {:error, reason} (StatifierPersistence.Executor), and Driver's own dispatch ends in an accepting catch-all, so the case is absent by circumstance rather than inapplicable. An executor that accepted the effect and returned {:error, reason} is not it; that is [:statifier_persistence, :effect, :failed]. A seam that can refuse an effect outright emits this event rather than minting a name |
[..., :effect, _] (11) | yes | every effect the advance produced, in the core's own list order, from persist_tail/7 - lifecycle effects (:done, :budget_exhausted) included, since the bridge needs them even though the executor never sees them |
[..., :trace, _] (9) | yes, under trace: true | the same pass; the flag rides the position (st-ADR-0060) and the gate stays in the core, which simply produces no trace effects when it is off |
driver: :persistence is on every one of them, and it is frozen (ADR-0009
decision 2). st-ADR-0067 open question 1 left the atom to this
repository; this is the answer, and changing it later is a breaking change
to a real consumer.
The session_id these emitters take is the chart's own _sessionid, read
out of the decoded datamodel - the same read Driver already performs for
event origin. This package never performs an extra lookup to obtain it,
and never invents one.
st-ADR-0067 decision 4's rule holds unchanged: no execution_id on
this family. The storage key is this package's vocabulary and travels on
family two.
Two shapes a reader of the emit sites will notice, both deliberate:
- The effect events are emitted up front, not interleaved with
execution. They report what the chart produced; what the host's
executor then made of each one is family two's
[:statifier_persistence, :effect, :failed]. Interleaving would also have to place the two lifecycle effects the executor never sees somewhere other than where the interpreter put them. - The
:initializespan is the one span not nested inside a step span.Interpreter.initialize/2runs inExecutions.create/4before the per-execution exclusion opens, so the bridge has no step span recorded for that process when it fires: it is not a child of the create's[:statifier_persistence, :execution, :step, :start]/:stoppair, and with nothing of the bridge's own open around it, it is the root of its own trace. Every other macrostep span is emitted inside the serialized unit and nests asst-ADR-0067decision 6 expects. Both halves of every one of them are emitted inside one synchronous call, so decision 5's "a span never crosses a persist boundary" holds structurally.
Family two: the storage-phase contract
Conventions adopted from the family
- Measurements are numbers; metadata is everything else, integer-valued
indexes included, because an opaque index has no numeric meaning to
average.
child_indexis therefore metadata. - One definition site, enumerable ahead of a call.
StatifierPersistence.Telemetrybuilds every name from module attributes holding literal atoms, andevents/0returns the full list, the wayStatifier.Telemetry.events/0andStatifierOban.Telemetry.events/0do. The bridge attaches one handler per event name under its own handler id and cannot do that for a list it has to hand-copy. Deriving a name segment from a module name at runtime is forbidden for the same reason (and by Credo'sUnsafeToAtom). - Moduledoc shape: a one-line opener naming ADR-0009, a section per
structural rule, then a markdown table of Event | Measurements | Metadata
per family, with each family's emission gate stated above its table.
Each emitter's
@docnames the exact event it emits. - Amendment discipline. Adding a measurement or metadata key to an existing event is an amendment and is fine; renaming or removing one, renaming an event, or changing the driver atom is breaking and needs a new ADR. A bridge that needs data these events lack gets a new field here - it never reaches into this package.
- No configuration knob and no sampling knob. Emission is
unconditional:
:telemetry.execute/3on an event with no handlers is a lookup and a return, and every option this package carries is a seam a host must state explicitly (ADR-0002). Nothing in this family scales with microstep count, so upstream'strace: truegate has no counterpart.
Deliberate departures from sob-ADR-0006, and why
statifier_oban landed its half first and invited a sibling with a
genuinely different shape to say so rather than copy out of deference.
Two of its decisions do not transfer, and one does.
Adopted: the [:package_name, ...] prefix, fixed and not
configurable, because the bridge must name the events at compile time and
a per-host prefix would make its attach list depend on host configuration
it cannot see. st-ADR-0067 decision 6 names this exact namespace
independently.
Departure 1: the identity key is execution_id, not scope.
StatifierOban.Timer.Key's scope is either a live session's id or a
host's durable execution id and that package cannot tell which, so scope
is the only honest name available to it. Here both exist as distinct named things:
execution_id is this package's storage key, present on every seam, and the
logical _sessionid is upstream's correlation key carried in the position
blob. Naming either of them scope would discard information this package
has. session_id rides where a position has already been decoded and is
explicitly nil otherwise.
Departure 2: the step seam is a :start / :stop pair. That record's
rule - no pairs - rests on Oban already owning every interval it could
bracket. This package owns an interval nobody else measures: lock, load,
decode, identity-check, advance, execute effects, persist. The upstream
macrostep span is expected to nest inside it, and the bridge's nesting needs
an outer span that is genuinely open to record in its table, which a single
event carrying a duration cannot provide. span_ref keeps st-ADR-0040
decision 2's semantics exactly: a fresh make_ref/0 per span, on both
halves, the only pairing key. Both halves are emitted inside one function
call, so st-ADR-0067 decision 5's "a span never crosses a persist
boundary" holds structurally.
Everything that is not the step seam is a single point-in-time event, on that record's own reasoning.
The step seam
Brackets one serialized drive - Executions.create/4, Executions.step/5,
Executions.fail/4 or Executions.cancel/3 inside serialized/5. Emitted
on the calling process. The [:statifier, :session, :macrostep, ...] span opens
and closes inside it.
| Event | Emitted from | Measurements | Metadata |
|---|---|---|---|
[:statifier_persistence, :execution, :step, :start] | Executions, immediately inside serialized/5 | system_time, monotonic_time | execution_id, entry, span_ref |
[:statifier_persistence, :execution, :step, :stop] | the same call, on every return path | duration, monotonic_time | execution_id, session_id, content_hash, entry, outcome, status, reason, span_ref, invoke_id, child_count |
[:statifier_persistence, :execution, :step, :exception] | the same call, in place of the stop, when the drive raises, throws or exits | duration, monotonic_time | execution_id, entry, span_ref, kind, reason, stacktrace |
[:statifier_persistence, :execution, :lock] | serialized/5, after strategy.with_execution/3 returns or refuses; Executions.unpark/3, the same way, outside any step span | duration (the wait, not the held time), system_time | execution_id, strategy, outcome, reason |
entry is which public door was used: :create, :step,
:done_invocation, :failed_invocation, :answer_parent, :fail,
:cancel. It is a fixed vocabulary and it is the dimension an operator
slices by first, because a :done_invocation step and a :step step have
different expected shapes.
outcome on the stop is :ok, :discarded or :error, mirroring the
three return shapes exactly. status is the execution's resulting
Adapter.execution_status (:active, :completed, :failed,
:cancelled) and is nil when the step did not reach a write. A step
never results in the fifth arm, :needs_migration (ADR-0014): a delivery
to a parked execution reaches no write, so its stop carries status: nil,
outcome: :error and the bare atom :needs_migration as reason rather
than the error term, which carries the execution. reason is the error term on
an :error outcome and nil otherwise - and it is a term, so a consumer
folding it into a metric dimension must narrow it first.
session_id is nil on the stop when the step never got as far as a
decoded position: a terminal-execution discard reads the execution record
only, and a lock refusal or an identity refusal never loads at all.
A step span closes exactly once: with :stop on a return, or with
:exception when anything inside the drive raises, throws or exits - the
host's executor or event builder, an adapter, the serialization strategy.
The raise then reaches the caller unchanged, with its original stacktrace;
nothing is rescued to a return value. The keys are the ones
:telemetry.span/3 puts on its own :exception event: the start half's
execution_id, entry and span_ref, plus kind (:error, :throw or
:exit), reason and stacktrace. The raised term and its stacktrace can
carry any value the failing code held - an event builder is called with
the decoded machine state, and a failed match on it raises with the whole
state, datamodel included - and the datamodel is never on an event (see
"Cardinality and disclosure" below). So the two fields that could carry
state are narrowed before the event is emitted, and every other field is
the start half's own:
reasonis the exception's module for an:error, a raw Erlang error normalized first (a failed match reportsMatchError); for a:throwor an:exitit is the thrown or exit atom, or:redactedfor any other term. The raised value itself never travels.stacktracekeeps each frame's module and function, replaces an argument list by its arity, and keeps only:fileand:lineof the location, dropping anything else a location carries.
The caller's re-raise is untouched: it sees the original reason and
stacktrace. A bridge that closes a span on :telemetry.span/3's exception
can close this one the same way.
invoke_id and child_count are the settlement dimensions, and they are
nil on every ordinary drive. StatifierPersistence.Driver sets them
beside entry: :answer_parent, so the step that carries a fan-out's whole
assembled answer through the parent's door is recognisable as that one and
not as any other invocation answer; child_count is nil for a
single-child subchart, which has no width. They are metadata rather than
measurements because they are dimensions of the span rather than
quantities it measured - the departure from the numbers-are-measurements
split that the ADR-0009 sp-8wv amendment records.
[:statifier_persistence, :execution, :lock]'s duration is the wait for
the per-execution exclusion, which is the number that says whether a host's
concurrency is fighting itself, and it is invisible from every other
surface. strategy is the serialization module
(StatifierPersistence.Serialization.AdapterLock by default); outcome is
:acquired or :unavailable; reason on :unavailable is the
{:serialization, term} payload, including
{:serialization, :not_supported} for an adapter that exports no
lock_execution/3. Executions.unpark/3 takes the same exclusion and
emits the same event, with nothing around it: an unpark is not a step and
opens no step span (ADR-0014's telemetry amendment).
The storage seam
Emitted from the Storage facade, above every adapter, on the calling
process.
| Event | Emitted from | Measurements | Metadata |
|---|---|---|---|
[:statifier_persistence, :adapter, :call] | every Storage facade function, around the adapter call | duration, system_time | adapter, callback, outcome, reason, execution_id, session_id, content_hash |
[:statifier_persistence, :identity, :refused] | Storage's own precheck_identity/4, every writer's identity arm, and persist_tail/7 | system_time | execution_id, session_id, stage, reason, stored_content_hash, supplied_content_hash |
callback is the name of the Storage.Adapter callback the facade called -
:init, :save_chart, :fetch_chart, :save_position, :fetch_position,
:insert_execution, :fetch_execution, :update_execution,
:supports_metadata?, :list_executions_by_metadata,
:supports_execution_outcome?, :list_execution_states_by_metadata,
:supports_content_hash_query?, :count_executions_by_content_hash,
:list_active_execution_ids_by_content_hash, :supports_chart_retirement?,
:retire_chart, :supports_retired_info?, :fetch_retired_info,
:supports_input_log?, :append_input, :list_inputs - a closed vocabulary
fixed by the behaviour. The behaviour's other two callbacks never appear:
isolate/1 is called by the conformance suite alone, and
lock_execution/3 is taken through the serialization strategy, which
[:statifier_persistence, :execution, :lock] reports. A retired-chart check on an adapter that declares
supports_retired_info?/1 reports :supports_retired_info? and
:fetch_retired_info where one on any other adapter reports :fetch_chart,
so a handler that matches callback exhaustively names all three.
adapter is the module. Between them they answer
"which storage call is slow" without a host instrumenting its own adapter,
and they answer it for the in-memory adapter too, which no SQL tracer sees.
outcome is :ok or :error; reason carries the adapter's own error
arm (:chart_not_found, :position_not_found, :execution_exists,
:execution_not_found, :metadata_unsupported, {:adapter, term}) and the
facade's capability refusals (:child_listing_unsupported,
:execution_position_missing, :not_a_statifier_blob,
{:unsupported_format_version, term}). Which of execution_id,
session_id and content_hash are present is whatever that callback is keyed by;
the rest are nil.
[:statifier_persistence, :identity, :refused] is the sharpest event in
this contract. stage is :position, :execution or :chart; reason is
:identity_mismatch or :unidentified_chart. A mismatch means a chart
revision changed under a live execution - the exact drift the guard exists
to catch (ADR-0003) - and a host that cannot count it learns about the deploy
that caused it from a support ticket. Only the two content_hash values
travel, never the Identity structs the error term carries; see
"Cardinality and disclosure" below.
An adapter call inside a lock inside a step nests three deep in the bridge, through its own span table, with no propagation machinery involved.
The execution lifecycle seam
| Event | Emitted from | Measurements | Metadata |
|---|---|---|---|
[:statifier_persistence, :execution, :created] | Executions.create/4, after the insert | system_time | execution_id, session_id, content_hash, child?, metadata? |
[:statifier_persistence, :execution, :terminated] | Executions.create/4, Executions.step/5, Executions.fail/4, Executions.cancel/3, on any terminal write | system_time | execution_id, session_id, content_hash, status, driven_by, reason |
[:statifier_persistence, :execution, :discarded] | Executions.step_tail/7, step_loaded/8, repair_terminal/4, and fail/cancel's terminal arms | system_time | execution_id, entry, reason, repaired? |
[:statifier_persistence, :execution, :migrated] | Executions.migrate/4, after its serialization section returns; Executions.migrate_tree/4, once per node it re-pins, children first, after every exclusion is released | system_time | execution_id, from_content_hash, to_content_hash, dropped |
[:statifier_persistence, :execution, :unparked] | Executions.unpark/3, after its serialization section returns, when it wrote :active | system_time | execution_id, content_hash |
[:statifier_persistence, :effect, :failed] | execute_effects/3 and reenter_failures/4 | system_time | execution_id, session_id, content_hash, kind, executor, reason, reentered? |
[:statifier_persistence, :drive, :turns_exhausted] | Driver's turn loop, on {:turns_exhausted, n} | system_time, turns | execution_id, entry |
driven_by on :terminated is :chart or :host, and it is the reason
this event exists at all. A chart-driven termination is also a
[:statifier, :session, :halt]; Executions.fail/4 and
Executions.cancel/3 are not. No interpreter runs on those paths -
ADR-0004 decision 6 makes abandonment a host decision about the execution
rather than a chart transition - so upstream emits nothing, and a host counting :halt alone would
undercount its own terminations by exactly the ones it caused itself.
status is :completed, :failed or :cancelled; reason is the
execution record's short failure string, which ADR-0004 decision 1 already
constrains to be console-readable, or nil.
:discarded's reason is a closed vocabulary of the three ways a
delivery becomes a non-event: :terminal_execution (the record was already
terminal, read before any position decode), :builder_declined (an event
builder returned :discard under the exclusion), and :position_terminal
(Interpreter.handle_event/2 returned {:error, :not_running}, the
structural backstop for a record whose :active status lied). Only the
third sets repaired?: true, and a host seeing it regularly has a
durability bug upstream of this package - which is precisely why the
repair path is worth a countable event rather than a silent fix.
[:statifier_persistence, :execution, :migrated] is the one event a
migration emits, once per execution Executions.migrate/4 re-pins, and
nothing is stored as a trace of it (ADR-0013 decision 5).
Executions.migrate_tree/4 is its second emitter and adds no key: one
event per node its one store unit re-pinned, the children's before the
root's, emitted once the unit has returned and every exclusion is
released (ADR-0015 decision 5). A migration has
two charts in hand, so the event names both hashes rather than carrying one
content_hash: from_content_hash is the chart the execution was pinned
to and to_content_hash the chart it is pinned to now. dropped is the
list of state ids the plan dropped that were in the execution's
configuration, [] when it dropped none; a drop is an operator exit and is
never an authored transition, so it has no family-one event of its own. A
migration is not a step: it opens no step span and takes no entry. A
refused or parked migration emits nothing from this family, and whether it
should is left open by ADR-0013. A refused or parked tree emits nothing
either, for any node.
[:statifier_persistence, :execution, :unparked] is an unpark's own
event, beside the lock event and its adapter calls: once per
Executions.unpark/3 that writes a :needs_migration execution back to
:active, after the serialization section returns. content_hash is the chart the execution
was parked on and goes on under, the one chart an unpark has in hand.
session_id is not on it, because an unpark decodes no position. An
unpark that writes nothing emits no :unparked: an :active execution,
which answers {:ok, execution} unchanged, a terminal one, which is
discarded with no :discarded event, an absent one, and a refused lock.
An unpark is not a step: it opens no step span and takes no entry
(ADR-0014's telemetry amendment).
[:statifier_persistence, :effect, :failed] is where the executor seam's
verdicts land. kind is the effect's kind atom, executor is the module
(or :fun for the arity-2 form), reason is the {:error, reason} term
it returned, and reentered? says whether ADR-0004 decision 4's
error.communication re-entry was opened for it - false for an
observational effect, whose failure is discarded because observation must
never steer an execution, and false for a failure inside a re-entry wave,
which is single-wave by design. Nothing wraps a successful executor call: the
host's work is the host's to instrument, and the step span already bounds
it.
The durable-subchart seam (ADR-0008)
Emitted on the parent's stepping process, at dispatch time.
| Event | Emitted from | Measurements | Metadata |
|---|---|---|---|
[:statifier_persistence, :child, :started] | Driver.start_child/3, after adopt_child/3 | system_time | parent_execution_id, child_execution_id, invoke_id, child_index, content_hash, session_id |
[:statifier_persistence, :child, :refused] | the same chain, on any refusal | system_time | parent_execution_id, invoke_id, reason |
[:statifier_persistence, :child, :recorded] | Driver.record_and_settle/5, after the child's own answer is written | system_time | parent_execution_id, child_execution_id, invoke_id, child_index, outcome |
[:statifier_persistence, :child, :answered] | Driver.answer_parent/3, after the parent's door returns | system_time | child_execution_id, parent_execution_id, invoke_id, outcome, child_count, failed_count, delivery |
[:statifier_persistence, :child, :settled] | Driver.settle/3, once per decision | system_time, child_count, completed, failed, cancelled, unstarted | parent_execution_id, invoke_id, policy, decision |
[:statifier_persistence, :child, :cascade_cancelled] | Executions.cascade_cancel/3, after the sweep | system_time, count, retained | parent_execution_id, invoke_id |
content_hash on :started is the child's pinned hash - ADR-0008
decision 2's mandatory identity pin, recorded in the child's linkage
metadata - which is what lets a consumer tell a child restarted against a
redeployed chart from one that was not. session_id is the child's own
logical session, so the bridge can stitch the child's macrostep spans to
this event without reading Execution.Linkage.
:refused's reason is ADR-0008 decision 4's closed refusal set:
:child_listing_unsupported (the adapter cannot host children),
:unidentified_chart (the resolved child chart carries no identity),
:execution_exists, and a Statifier.Invoke.Source.resolve/2 reason. All
four reach the chart as
{:failed, reason: "child_execution_creation_failed", detail: detail}, so a host sees them there too - but only as a chart-level
failure, without which of the four it was.
:answered's outcome is :done or :failed. For a single child it
mirrors the two doors, and child_count and failed_count are nil: one
child is not an invocation with a width. For a fan-out it is the
invocation's aggregate - :failed when any index failed - which is not
the door: a fan-out that lost an index still answers through
done_invocation/5 with a dense list, because st-ADR-0068's failure shape
is inside the entry rather than around the list. Reporting the door said
outcome: :done for a settlement that failed, which is the one thing a
consumer counts this event to learn (the ADR-0009 sp-8wv amendment).
failed_count is how many entries in that list failed.
:answered's delivery is what the parent's door answered, from a
closed vocabulary: :delivered (the parent took the answer),
:discarded (the parent had already left the invocation, ADR-0007
decision 3), :needs_migration (the parent is parked and refused the
answer whole, ADR-0014 decision 2) or :error (any other error, whose
term does not travel). The automatic answer - the one a child's own drive
makes once it is terminal - returns the child's own result whatever the
parent's door answered, so this key is where a refused answer reaches the
host. The event is emitted on the process driving the child, before that
drive returns, so a handler seeing delivery: :needs_migration runs
beside the caller holding the child's result; delivering the answer again
through Driver.answer_parent/3 once the parent leaves the arm is the
host's. For a fan-out the children's recorded answers stay on their own
executions, and answering again through any one child settles the
invocation again (the ADR-0009 sp-6neq amendment).
:recorded and :settled are the fan-out settlement seam, and both are
emitted inside the parent's settlement exclusion, which is what makes
them trustworthy: a :recorded cannot claim an answer the settlement that
follows will not read.
:recorded fires once per child answer written to a child's own execution
record. Every index but the settling one records an answer that reaches no
door at all - nine of ten for a ten-wide fan-out - so this is the only
surface those answers appear on.
:settled fires once per decision the settlement section reaches, and not
at all for a read that failed before reaching one. decision is :answer
or :not_yet. The four tallies are read off the same states the decision
was made from, the first_error cancels included, and unstarted is the
indexes with no execution of their own at all - which is what tells a
fan-out still starting from one that is stuck. They partition child_count only
once every index has an execution.
count on :cascade_cancelled is how many executions the sweep actually
cancelled and retained is how many it found already terminal and left
alone - ADR-0008 decision 5's retain semantics as a number. Both are
legitimately 0: a cancel matching nothing is a no-op, not an error, and a
crash-recovering host may replay a cancel whose start it never durably
recorded. invoke_id is nil for the whole-parent sweep and set for the
per-invocation one.
A child is a separate execution with its own logical session, so its own
steps produce their own step spans and their own macrostep spans - not children
of the parent's. Parenthood would hold the parent's trace open for the
child's whole life, which on this package's target hosts is days. The
bridge links instead, from :started.
Cardinality and disclosure
Every metadata key above is bounded by the chart or by a closed vocabulary, with two exceptions, both deliberate.
execution_id is host-supplied and unbounded. It is present as a
correlation id for a span or a log line, never as a metric dimension - the same status
job_id has in statifier_oban and id has in Oban itself.
reason is a term on several events. Where this note names a closed
vocabulary (:discarded, :child, :refused, the adapter arms) it is safe
to dimension on. Where it carries an arbitrary executor or adapter error
(:effect, :failed, :adapter, :call's {:adapter, term}, the step
stop), a consumer must narrow it before it becomes a dimension. The step
exception's reason is already narrowed to a module or an atom. A host executor returning a per-effect struct
there will blow up any metric keyed on it, and no change here can prevent
that.
Nothing host-opaque and nothing from the datamodel is ever on an event.
Never emitted, in any form - not truncated, not hashed, not "just the
keys": the chart_blob, the position_blob, the identity_blob, the
ADR-0006 metadata map, the chart's datamodel, an invoke's params, and a
:done effect's donedata.
The metadata map is the one a well-meaning implementer will reach for,
because it holds exactly the tenant and correlation ids an operator wants
to slice by. It is excluded anyway. It is unbounded by construction and
host-defined, so no cardinality budget can be reasoned about here; and
ADR-0006 decision 2 already records that it sits at rest in the clear
outside :blob_type encryption, so a host that filed something it should
not have would have it leave the database on this channel. metadata? on
[:statifier_persistence, :execution, :created] is a boolean - whether a
non-empty map was supplied - and that is the whole of what this contract
says about it. A host wanting its own dimensions has execution_id on
every event and its own table to join.
The identity guard's refusal carries the same rule into a place it is easy
to miss. The error term is {:identity_mismatch, stored, supplied} with
two whole Identity structs; the event carries the two content_hash
values and nothing else from them. A content hash is a digest of a chart
document and is the key this package and its host already exchange in the
open; the envelope around it is not.
caller_context does not appear in this family at all. This package
originates none. It rides family one from the driving event, where
Statifier.Telemetry puts it and where st-ADR-0063 intends it, and the
bridge reads it there.
The bridge half
opentelemetry_statifier is the only package in the family that calls an
OpenTelemetry API (st-ADR-0062), and it bridges siblings as separate
per-library setup calls. This package's half of that bridge is an
obligation, not code: the promise that the events above carry everything
the bridge needs, so it never reads a chart blob, a position blob, an
execution record, StatifierPersistence.Execution.Linkage, or an ADR-0006
metadata map.
Span construction, handler attachment and the span table are the bridge
repo's decisions and are not specified here.
What the events are built to let the bridge do:
Family one needs no bridge work at all. These are upstream's own 27 events with
driver: :persistencein metadata, mapped tostatifier.driverby the mapping the bridge already has. A durable execution becomes visible the moment the emit sites land, with no second handler table and no second attribute vocabulary. That isst-ADR-0067decision 1's whole argument, and this package is the reason it was made.session_idmaps tostatifier.session_id, the same attribute upstream uses. That is what joins family two's events to family one's spans for a consumer that is not relying on ambient context - and it is why family two carries the honestnilrather than a fabricated value on the events emitted before a position is decoded.execution_idmaps tostatifier_persistence.execution_id, a new attribute in this package's own namespace. Every other measurement and metadata key maps by name intostatifier_persistence., as upstream's attribute rule already specifies for its own namespace.Nesting is bridge-owned, three deep. A step span opens and the bridge records it in its own span table, tagged with the emitting pid; the adapter events and the upstream macrostep span land inside it because the bridge parents them from that row, never from the process's ambient OTel context, which it does not read (
ots-ADR-0004decision 4). A job or request span a host orstatifier_obanalready had open in the same process therefore does not parent the step span - with nothing of the bridge's own open around it, the step span is the root of its own trace.st-ADR-0067decision 6 describes the shape inside it. No propagation machinery is involved, and no package has to know about another.Resume and cross-step stitching use links, never parenthood, and the position blob carries nothing trace-shaped. ADR-0009 decision 6 settles this. Within a node, consecutive macrosteps of one execution stitch through the bridge's last-span-context table keyed on
session_id, exactly as statifier-ex'sdocs/opentelemetry.mdalready stitches session macrosteps - the durable case is not special. Across a node or a deploy, where that table misses, a step links to its driving trace if and only if the host attached ast-ADR-0063caller_contextto the driving event; with none, each macrostep is an unlinked trace correlated bystatifier.session_id. That is the standard detached case, not an error. Whether this package should reserve a linkage key carrying a W3Ctraceparentso cross-node resumes link without host cooperation is deferred, with the trigger stated in ADR-0009 decision 6.A child execution links to its parent, and is not parented by it. From
[:statifier_persistence, :child, :started]the bridge hasparent_execution_id,child_execution_id,invoke_id,child_index, the pinnedcontent_hashand the child'ssession_id- everything needed for a link, with nothing read out ofExecution.Linkage. Parenthood would hold the parent's trace open for the child's whole life.A fan-out is reassembled from
:recordedand:settled, not from the parent's span. The invocation is not an interval anything here owns: its children run on N nodes over N intervals, and the only thing that happens at one place and time is each settlement decision. So a bridge reads the invocation as the(parent_execution_id, invoke_id)pair those two events share with:startedand:answered, counts the recorded answers againstchild_count, and reads the last:settled-decision: :answer- as the point the parent moved.triggeris a pass-through string. No event here originates one, but the macrostep events this package emits throughStatifier.Telemetrycarry upstream's, and it is copied and never validated against an enum. statifier-ex owns that vocabulary and is still growing it (:resumeis the most recent addition); a consumer that hardcodes today's value set is wrong on the next release.Trace-off degrades to nothing, structurally. With no bridge attached,
:telemetry.execute/3on an event with no handlers is a lookup and a return. There is no build-time flag, no compile-time removal, and no option to disable emission. Upstream'strace: truegate applies to upstream's nine:traceevents and rides the position (st-ADR-0060); nothing in family two scales with microstep count, so it has no counterpart here.
For a host that is not using the bridge
The events are plain :telemetry. A host attaching Telemetry.Metrics
gets, with no OpenTelemetry anywhere:
- a distribution over
[:statifier_persistence, :execution, :step, :stop]'sduration, dimensioned byentryandoutcome- the durable stepper's own latency, which nothing else measures; - a distribution over
[:statifier_persistence, :execution, :lock]'sdurationand a counter on itsoutcome- whether the host's concurrency is fighting itself for the same execution; - a distribution over
[:statifier_persistence, :adapter, :call]'sdurationbycallback- which storage call is slow, in-memory adapter included; - a counter on
[:statifier_persistence, :identity, :refused]- the deploy-drift alarm, which should normally be flat at zero; - counters on
[:statifier_persistence, :execution, :terminated]bydriven_byandstatus, and on[:statifier_persistence, :execution, :discarded]byreason; - a counter on
[:statifier_persistence, :execution, :migrated]byfrom_content_hashandto_content_hash- how many executions a sweep over the drained query has moved off a chart; - a counter on
[:statifier_persistence, :execution, :unparked]bycontent_hash- how many parked executions a host put back to work unmigrated; - counters and a
countdistribution on the child seam - fan-out, refusals, and how much a cascading cancel actually swept.
That is why the contract is defined in events rather than spans, and it is
the same reason st-ADR-0062 gave for the bridge being a separate package.