ash_onetime emits a closed, value-free telemetry surface for keyed-effect admission. Every
event carries measurements (:duration in native units, or :count) and a fixed
metadata shape %{strategy:, resource:, action:, result_class:} — never the request
payload, response value, key material, fingerprint, or token. This is an invariant, not a
convention: the telemetry module validates the shape before emission and the
forbidden-telemetry mutation fixture in scripts/check_mutations.exs pins it.
Do not attach handlers that expect data-bearing metadata — those fields are never
present, and a handler that indexes into them will silently get a nil. Attach by event
name and branch on result_class (an atom), which is always present.
Events
All events are prefixed [:ash_onetime, <event>]. Each event's metadata includes the
result_class atom named below.
| Event | Measurements | result_class values |
|---|---|---|
[:ash_onetime, :admission] | :duration | :admitted :rejected :failed |
[:ash_onetime, :conflict] | :count (always 1) | :complete :processing :nonce_used :malformed |
[:ash_onetime, :replay] | :duration | :returned :rejected |
[:ash_onetime, :fingerprint_mismatch] | :count (always 1) | :rejected |
[:ash_onetime, :verification] | :duration | :verified :rejected :timeout |
[:ash_onetime, :encoding] | :duration | :stored :rejected :rollback :failed |
[:ash_onetime, :cache] | :count (always 1) | :hit :miss :stale :corrupt :failure :timeout :stored :expired :oversized :disabled |
[:ash_onetime, :cleanup] | :count | :claims_deleted :partitions_dropped :partitions_created |
[:ash_onetime, :reap] | :count | :claims_reaped |
[:ash_onetime, :external_recovery] | :duration | :processing_committed :execute_succeeded :recover_succeeded :absence_proven :outcome_unknown :external_effect_unavailable :finalize_locked :replayed |
[:ash_onetime, :store_uncertainty] | :count (always 1) | :sent :unknown :disconnected :lock_timeout :worker_timeout |
[:ash_onetime, :untracked_execution] | :count (always 1) | :checkout_unavailable |
strategy is :idempotency or :one_time_nonce; resource and action are the module and
action atom the protection is declared on.
A runnable handler
This handler counts admissions by result class per resource/action, and records admission
durations into a histogram. Attach it in your application startup (e.g. your app's
start/2), once per VM.
defmodule MyApp.AshOnetimeMetrics do
@doc """
Attaches the admission metrics handler. Idempotent — safe to call once at boot.
"""
def attach do
:telemetry.attach_many(
"my-app.ash-onetime.admission",
[[:ash_onetime, :admission]],
&__MODULE__.handle_admission/4,
nil
)
end
def handle_admission(_event, measurements, metadata, _config) do
# metadata is always %{strategy:, resource:, action:, result_class:} — branch on the atom.
labels = {metadata.resource, metadata.action, metadata.result_class}
:telemetry.execute([:my_app, :ash_onetime, :admission_count], %{count: 1}, labels)
# :duration is present on :admission events. Guard anyway — other events use :count.
if duration = measurements[:duration] do
:telemetry.execute([:my_app, :ash_onetime, :admission_duration], %{duration: duration}, labels)
end
end
endOut-of-the-box attach helper
If you want the full closed event surface routed into a single downstream stream without
hand-rolling the event list and the count/duration split, the library ships an opt-in helper
mirroring Oban.Telemetry.attach_default_logger/1:
# in your application startup, once per VM, after the repo is started
AshOnetime.Telemetry.attach()attach/0 attaches a handler to every [:ash_onetime, *] event and re-emits each as a
downstream [:ash_onetime, event, :metric] event carrying the same atoms-only metadata and a
normalized :count or :duration measurement. Attach your own aggregator
(Telemetry.Metrics reporter, a custom handler, an ETS counter) to the :metric events:
:telemetry.attach_many(
"my-app.ash-onetime.metrics",
[
[:ash_onetime, :admission, :metric],
[:ash_onetime, :store_uncertainty, :metric],
# ...or the full [:ash_onetime, event, :metric] list
],
&MyApp.Metrics.handle/4,
nil
)The handler is a pure router — no state, no new metadata, no dependency on telemetry_metrics
— so the value-free guarantee is preserved. attach/1 accepts a :name to attach alongside
another consumer; detach/1 undoes it. If you already maintain a Telemetry.Metrics reporter
centrally, declare the counter/summary definitions in your own MyApp.Telemetry against the
original events (the example above) rather than using attach/0 — both paths are supported.
Route these onward into your metrics backend (e.g. :telemetry_metrics statsd/prometheus
reporters) by attaching Telemetry.Metrics definitions in your release or MyAppWeb.Telemetry:
summary("my_app.ash_onetime.admission_duration.duration",
tag: [:resource, :action, :result_class],
unit: {:native, :millisecond}
)
counter("my_app.ash_onetime.admission_count.total",
tag: [:resource, :action, :result_class]
)What to alert on
[:ash_onetime, :admission]withresult_class: :failedrising — admission store errors (non-application failures). For idempotency these fail closed; for nonces they always fail closed. A spike means the store, not the client.[:ash_onetime, :store_uncertainty]— sent/unknown/disconnected/lock_timeout outcomes. These are the authoritative-state-unavailable conditions; for idempotency they may be the optional:execute_untrackedpath (untracked_executionfollows), for nonces they fail closed.[:ash_onetime, :external_recovery]withresult_class: :outcome_unknown— an external effect whose result could not be settled. These are the conservative ambiguous-outcome cases; see External effects and recovery.[:ash_onetime, :untracked_execution]— an idempotent action executed without a stored admission after a checkout failure. It is correct by design but worth visibility.
Events are deliberately low-cardinality: resource/action/result_class are atoms, so a
high-cardinality label (a key value, a payload hash) can never leak through this surface.
Span-style events (start/stop)
ash_onetime emits point events, not span events. There is no
*.start / *.stop / *.exception triple on any event family: every event is a single
:telemetry.execute/3 carrying :duration (latency-bearing events) or :count (count
events) plus the fixed atoms-only metadata described above. This is a deliberate invariant,
not a gap to close.
Why no spans
The value-free metadata guarantee — the four atoms strategy / resource / action /
result_class, and nothing else — is enforced by the telemetry module's validator and
pinned by the forbidden-telemetry mutation fixture in scripts/check_mutations.exs.
:telemetry.span/3 cannot satisfy it:
span/3force-injectstelemetry_span_contextonto every event's metadata unconditionally (deps/telemetry/src/telemetry.erl:446-448). The closed four-atom metadata shape would gain a fifth key on every span event by construction.span/3emits:exceptionwithkind/reason/stacktracemerged onto the metadata, inside the span, before any callerrescuecan catch it (telemetry.erl:378-387). The span'scatchfires the:exceptionevent and then re-raises; the admission entry points'rescue/catchcoerce the re-raise to a typed:failedresult, but the event has already been dispatched. An exceptionreasonorstacktracecan carry secret-bearing terms (anAshOnetime.Errorstruct, aPostgrex.Errorwith query text, a typed-argument mismatch carrying a token), and the surface exists precisely to keep such terms out of telemetry.
For the latency-bearing event families (:admission, :replay, :verification,
:encoding, :external_recovery), :duration is already on the point event — p99/SLO
measurement does not require spans.
Trace correlation: wrap at the consumer boundary
If you need start/stop pairing for distributed-trace correlation (stitching an admission
into a cross-service trace), apply :telemetry.span/3 at your call site, where you own
both the metadata and the exception handling:
def MyApp.create_safely(input) do
{result, _} =
:telemetry.span(
[:my_app, :ash_onetime, :admission],
%{strategy: :idempotency, resource: MyResource, action: :create},
fn ->
case Ash.create(MyResource, input) do
{:ok, record} -> {{:ok, record}, %{result_class: :admitted}}
{:error, error} -> {{:error, error}, %{result_class: :rejected}}
end
end
)
result
endYou control the start/stop/exception metadata at that boundary, so any values you
emit are your decision, not the library's. The library's point events remain the
authoritative, value-free record of the classified outcome.