OpentelemetryStatifier.SpanTable (OpentelemetryStatifier v0.5.0)

Copy Markdown View Source

Owns the ETS table that holds open macrostep spans and, per session, the last span context ots-j82's links read.

The table is created in init/1 and otherwise untouched by this process - it is :public and :named_table so handlers read and write it directly from the session process that calls OpentelemetryStatifier.Handler.handle_event/4, with no message hop through this GenServer. Owning the table this way (rather than letting setup/1 create it) means the table exists before any handler can fire, survives every session process, and gives ots-lt6's sweep timer a natural home - opentelemetry_ecto's latent "owned by whoever called setup" bug does not reach this package.

Row shapes, fixed here because ots-j82 and ots-lt6 both read them directly against the table name carried in OpentelemetryStatifier.Config:

{{:span, span_ref},             session_id, %SpanEntry{}}
{{:last_span_ctx, session_id},  session_id, span_ctx}
{{:invoke_parent, session_id},  session_id, span_ctx}
{{:session_pid, session_id},    session_id, pid}
{{:sibling_span, span_ref},     pid,        %SiblingEntry{}}
{{:parent_span, ref},           pid,        %ParentEntry{}}

The :sibling_span row is the sibling families' half (ADR-0004): a [:statifier_persistence, :run, :step, :start] opens one keyed on the same span_ref convention, and it carries a pid in element 2 where the macrostep rows carry a session_id, because a step span is scoped to the process that drove it rather than to a logical session. That is what makes fetch_innermost_sibling_span/2 a one-match_object lookup for "is one of this bridge's own spans open around me, in this process" - the question every nested sibling span and every durable macrostep span asks.

The :parent_span row is the foreign-driver half of the same mechanism (ADR-0004 decision 4, and its 2026-09-02 Notes): a host that runs a durable stepper this package knows nothing about declares its own enclosing span through OpentelemetryStatifier.Parent.register/2, and the row it writes is read by exactly the same "is something of mine open around me, in this process" lookup. It carries a pid in element 2 for that reason. The one thing it is not is a span this bridge owns: sweep/1 deletes an abandoned :parent_span row without ending its span, where a :sibling_span row's span is ended with an error status.

The :invoke_parent row is written when a child session's :init event names an invoked_by parent whose macrostep span is open at that moment, and consumed (removed) by the child's own :initialize macrostep start, which turns it into a span link.

The :session_pid row records the session process behind session_id - the handler runs inside the session process, so self() at event time is that pid. It exists for the sweep: :terminate does not fire on a brutal kill, so sweep/1 walks these rows and, for every session whose process is no longer alive, ends any still-open macrostep spans with an error status and deletes all the session's rows - the design note's "sweeps entries whose sessions no longer exist rather than trusting the event alone". This GenServer runs the sweep on a timer; tests call sweep/1 directly.

The %SpanEntry{} in a :span row gains its macrostep counter after the fact: the macrostep :start event carries no such measurement, so the handler stamps it from the first intra-macrostep event that lands on the span. fetch_open_span_ctx/3 is the keyed (session_id, macrostep) read that field exists for.

session_id is duplicated into element 2 of every row shape so a sweeper can find every row for a session with one :ets.match_object/2 or :ets.match_delete/2, without decoding either row's structured value.

Summary

Functions

Returns a specification to start this module under a supervisor.

Removes the host-declared enclosing span under ref. The declared span is not ended - the host that opened it owns its lifetime. Removing a ref that is not there is :ok, so unregister/1 is idempotent and safe to call from an after block that may run twice.

Removes every row session_id owns, first ending any still-open macrostep spans with an error status carrying orphan_message - an orphan is reported, never silently dropped. Called by the handler's :terminate clause and by sweep/1 for sessions whose process died without a :terminate.

The context a span opened in pid right now should nest under: the innermost thing this bridge has recorded as enclosing that process, whichever of the two kinds it is, or :error when there is none.

As fetch_innermost_open_span/2, but also returns the span_ref the entry is keyed under, so a caller that has just read the row can write an updated one back through put_open_span/3 without a second scan of the table. OpentelemetryStatifier.Handler uses it on the span-event path, where every event both lands on the innermost span and carries the macrostep counter that span is missing.

Fetches the innermost open span for session_id - the entry with the greatest started_at among the session's open rows, since ADR-0039 re-entry can hold two spans open at once and an intra-macrostep event belongs to the most recently opened one. Returns :error when the session has no open span (an effect event racing a crash's cleanup is contract-legal, not a bug).

Fetches the innermost sibling span open in pid - the entry with the greatest started_at, since a parent run creating a durable child inside its own step holds two step spans open on one process. Returns :error when the process has none, which is the ordinary case for a host that attached only the sibling setups it uses.

Fetches the last span context recorded for session_id, returning {:ok, span_ctx} on a hit or :error for an unknown session.

Fetches the span context of the open macrostep span session_id recorded macrostep on, or :error when there is none - an unknown session, a macrostep whose span has already closed, or an open span that has not yet seen an event carrying the counter.

Fetches the process recorded for session_id by put_session_pid/3, or :error for a session the bridge has not seen. The sibling handlers use it to check that a session's open macrostep span belongs to this process before landing a span event on it - a delivery-seam event on an Oban worker must never write onto a span another process has open for the same scope.

Creates (or returns, if it already exists under this process) a :public/:named_table ETS table under name. Tests call this directly with a unique name per test so they never depend on the application-started table.

Records the parent macrostep span context a child session's :initialize macrostep will link to, keyed by the child's session_id.

Records span_ctx as the last span context observed for session_id.

Stores an open span's %SpanEntry{} under its span_ref.

Stores a host-declared enclosing span's %ParentEntry{} under ref, keyed for lookup by the process whose spans nest under it.

Records pid as the session process behind session_id, for the sweep's liveness check. Idempotent - the table is a :set, so a session's row is written once per macrostep at no accumulating cost.

Stores an open sibling span's %SiblingEntry{} under its span_ref, keyed for lookup by the process that opened it.

Ends the orphans a brutal kill leaves behind: for every :session_pid row whose process is no longer alive, delegates to delete_session/3, and for every :sibling_span row whose process is no longer alive, ends that span with an error status and deletes the row. A live process's rows are never touched - an open span on a live process is just a step or a macrostep in flight, whatever its age.

Looks up and removes the invoke-parent span context stored for session_id, returning {:ok, span_ctx} on a hit or :error when no parent was recorded - a session that was not invoked by anyone.

Looks up and removes the open span under span_ref, returning {:ok, entry} on a hit or :error on a miss - a :stop event with no matching :start is a contract-legal shape, not a bug, so this never raises.

Looks up and removes the open sibling span under span_ref. Returns :error on a miss, which - as for take_open_span/2 - is a contract-legal shape rather than a bug.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

delete_parent_span(table, ref)

@spec delete_parent_span(atom(), reference()) :: :ok

Removes the host-declared enclosing span under ref. The declared span is not ended - the host that opened it owns its lifetime. Removing a ref that is not there is :ok, so unregister/1 is idempotent and safe to call from an after block that may run twice.

delete_session(table, session_id, orphan_message)

@spec delete_session(atom(), String.t(), String.t()) :: :ok

Removes every row session_id owns, first ending any still-open macrostep spans with an error status carrying orphan_message - an orphan is reported, never silently dropped. Called by the handler's :terminate clause and by sweep/1 for sessions whose process died without a :terminate.

fetch_enclosing_ctx(table, pid)

@spec fetch_enclosing_ctx(atom(), pid()) :: {:ok, OpenTelemetry.Ctx.t()} | :error

The context a span opened in pid right now should nest under: the innermost thing this bridge has recorded as enclosing that process, whichever of the two kinds it is, or :error when there is none.

Two row shapes answer the same question and are ordered against each other on the one clock they share (System.monotonic_time/0): a :sibling_span row, which is a span this bridge opened itself from a sibling package's :start event, and a :parent_span row, which is a span a host declared through OpentelemetryStatifier.Parent.register/2. The most recently opened of them is the innermost, so a bridge-opened step span inside a host's declared span parents what follows, and so does a declaration made inside a step span.

A declaration whose registrant is dead is skipped: nobody is left to end that span or to withdraw the declaration, so nesting under it would attach live spans to an abandoned trace. Falling back to the next enclosing row - or, usually, to no parent at all - is the honest answer.

fetch_innermost_open_row(table, session_id)

@spec fetch_innermost_open_row(atom(), String.t()) ::
  {:ok, reference(), OpentelemetryStatifier.SpanEntry.t()} | :error

As fetch_innermost_open_span/2, but also returns the span_ref the entry is keyed under, so a caller that has just read the row can write an updated one back through put_open_span/3 without a second scan of the table. OpentelemetryStatifier.Handler uses it on the span-event path, where every event both lands on the innermost span and carries the macrostep counter that span is missing.

fetch_innermost_open_span(table, session_id)

@spec fetch_innermost_open_span(atom(), String.t()) ::
  {:ok, OpentelemetryStatifier.SpanEntry.t()} | :error

Fetches the innermost open span for session_id - the entry with the greatest started_at among the session's open rows, since ADR-0039 re-entry can hold two spans open at once and an intra-macrostep event belongs to the most recently opened one. Returns :error when the session has no open span (an effect event racing a crash's cleanup is contract-legal, not a bug).

fetch_innermost_sibling_span(table, pid)

@spec fetch_innermost_sibling_span(atom(), pid()) ::
  {:ok, OpentelemetryStatifier.SiblingEntry.t()} | :error

Fetches the innermost sibling span open in pid - the entry with the greatest started_at, since a parent run creating a durable child inside its own step holds two step spans open on one process. Returns :error when the process has none, which is the ordinary case for a host that attached only the sibling setups it uses.

fetch_last_span_ctx(table, session_id)

@spec fetch_last_span_ctx(atom(), String.t()) ::
  {:ok, OpenTelemetry.span_ctx()} | :error

Fetches the last span context recorded for session_id, returning {:ok, span_ctx} on a hit or :error for an unknown session.

fetch_open_span_ctx(table, session_id, macrostep)

@spec fetch_open_span_ctx(atom(), String.t(), non_neg_integer()) ::
  {:ok, OpenTelemetry.span_ctx()} | :error

Fetches the span context of the open macrostep span session_id recorded macrostep on, or :error when there is none - an unknown session, a macrostep whose span has already closed, or an open span that has not yet seen an event carrying the counter.

This is a read, and deliberately only a read: it returns a stored context, never attaches one, so ADR-0003 decision 8's rule that the bridge neither inherits nor clobbers a process's ambient context is untouched. OpentelemetryStatifier.SpanContext.lookup/2 is the public face of it.

Under st-ADR-0039 re-entry a session can hold two macrostep spans open at once; they carry different counters in the ordinary case, and the innermost wins if they do not.

fetch_session_pid(table, session_id)

@spec fetch_session_pid(atom(), String.t()) :: {:ok, pid()} | :error

Fetches the process recorded for session_id by put_session_pid/3, or :error for a session the bridge has not seen. The sibling handlers use it to check that a session's open macrostep span belongs to this process before landing a span event on it - a delivery-seam event on an Oban worker must never write onto a span another process has open for the same scope.

new_table(name)

@spec new_table(atom()) :: atom()

Creates (or returns, if it already exists under this process) a :public/:named_table ETS table under name. Tests call this directly with a unique name per test so they never depend on the application-started table.

put_invoke_parent(table, session_id, span_ctx)

@spec put_invoke_parent(atom(), String.t(), OpenTelemetry.span_ctx()) :: :ok

Records the parent macrostep span context a child session's :initialize macrostep will link to, keyed by the child's session_id.

put_last_span_ctx(table, session_id, span_ctx)

@spec put_last_span_ctx(atom(), String.t(), OpenTelemetry.span_ctx()) :: :ok

Records span_ctx as the last span context observed for session_id.

put_open_span(table, span_ref, entry)

@spec put_open_span(atom(), reference(), OpentelemetryStatifier.SpanEntry.t()) :: :ok

Stores an open span's %SpanEntry{} under its span_ref.

put_parent_span(table, ref, pid, entry)

@spec put_parent_span(
  atom(),
  reference(),
  pid(),
  OpentelemetryStatifier.ParentEntry.t()
) :: :ok

Stores a host-declared enclosing span's %ParentEntry{} under ref, keyed for lookup by the process whose spans nest under it.

put_session_pid(table, session_id, pid)

@spec put_session_pid(atom(), String.t(), pid()) :: :ok

Records pid as the session process behind session_id, for the sweep's liveness check. Idempotent - the table is a :set, so a session's row is written once per macrostep at no accumulating cost.

put_sibling_span(table, span_ref, pid, entry)

@spec put_sibling_span(
  atom(),
  reference(),
  pid(),
  OpentelemetryStatifier.SiblingEntry.t()
) :: :ok

Stores an open sibling span's %SiblingEntry{} under its span_ref, keyed for lookup by the process that opened it.

sweep(table)

@spec sweep(atom()) :: :ok

Ends the orphans a brutal kill leaves behind: for every :session_pid row whose process is no longer alive, delegates to delete_session/3, and for every :sibling_span row whose process is no longer alive, ends that span with an error status and deletes the row. A live process's rows are never touched - an open span on a live process is just a step or a macrostep in flight, whatever its age.

A :parent_span row is swept when either its registrant or the process it nests spans for is gone, and its span is never ended: the host declared a span it owns, and ending someone else's span - possibly in the middle of the work it covers - would be worse than leaking a three-tuple until the next sweep.

take_invoke_parent(table, session_id)

@spec take_invoke_parent(atom(), String.t()) ::
  {:ok, OpenTelemetry.span_ctx()} | :error

Looks up and removes the invoke-parent span context stored for session_id, returning {:ok, span_ctx} on a hit or :error when no parent was recorded - a session that was not invoked by anyone.

take_open_span(table, span_ref)

@spec take_open_span(atom(), reference()) ::
  {:ok, OpentelemetryStatifier.SpanEntry.t()} | :error

Looks up and removes the open span under span_ref, returning {:ok, entry} on a hit or :error on a miss - a :stop event with no matching :start is a contract-legal shape, not a bug, so this never raises.

take_sibling_span(table, span_ref)

@spec take_sibling_span(atom(), reference()) ::
  {:ok, OpentelemetryStatifier.SiblingEntry.t()} | :error

Looks up and removes the open sibling span under span_ref. Returns :error on a miss, which - as for take_open_span/2 - is a contract-legal shape rather than a bug.