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.

EventMeasurementsresult_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
end

Out-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] with result_class: :failed rising — 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_untracked path (untracked_execution follows), for nonces they fail closed.
  • [:ash_onetime, :external_recovery] with result_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:

  1. span/3 force-injects telemetry_span_context onto 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.
  2. span/3 emits :exception with kind / reason / stacktrace merged onto the metadata, inside the span, before any caller rescue can catch it (telemetry.erl:378-387). The span's catch fires the :exception event and then re-raises; the admission entry points' rescue/catch coerce the re-raise to a typed :failed result, but the event has already been dispatched. An exception reason or stacktrace can carry secret-bearing terms (an AshOnetime.Error struct, a Postgrex.Error with 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
end

You 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.