Cairnloop.Outbound.Telemetry.Traces (cairnloop v0.5.1)

Copy Markdown View Source

Optional OpenInference-conformant trace event module for the Cairnloop outbound lane (Phase 26, D-03).

Mirrors Cairnloop.Governance.Telemetry.Traces (Phase 17, D17-01 / D17-03) — same architectural posture, swapped namespace + event vocabulary + attribution refs.

Namespace separation (D-03)

This module is SEPARATE from the bounded-metrics outbound paths emitted via Cairnloop.Telemetry.span/3 + .execute/3. It emits to a disjoint 4-segment event namespace:

[:cairnloop, :outbound, :trace, <event_atom>]

The bounded-metrics outbound paths live at:

[:cairnloop, :outbound, :triggered, :start | :stop | :exception]
[:cairnloop, :outbound, :bulk, :triggered, :start | :stop | :exception]
[:cairnloop, :outbound, :bulk, :triggered]
[:cairnloop, :outbound, :delivery, :sent | :failed]

No handler attached to any bounded-metrics path will receive trace events, and vice versa. The two namespaces are disjoint by the :trace segment in position 3. This isolation is proven in Cairnloop.Outbound.Telemetry.TracesTest.

Purpose

Hosts (and OI-aware consumers like Scoria, Phoenix.Tracer, OpenTelemetry exporters) can attach to [:cairnloop, :outbound, :trace, ...] to reconstruct an outbound-lane span tree (trigger lifecycle, bulk submit/refused, per-recipient delivery) without duplicating durable record content.

Trace events are emitted alongside existing bounded-metrics events — they are observability only, never workflow truth (CLAUDE.md "telemetry is observability only").

Zero Scoria dependency

This module calls :telemetry.execute/3 directly. It imports no Scoria-owned modules and does not route through Cairnloop.Telemetry (the bounded-metrics centralizer). If no handler is attached, the event is silently dropped by the :telemetry library — fail-closed.

OI span kinds (D-03 — execution-events-are-TOOL)

Trace events carry "openinference.span.kind" (string key per OI spec) in metadata:

  • Execution events (:delivery_sent, :delivery_failed): "TOOL"
  • All other outbound lifecycle events (trigger lifecycle, bulk lanes): "GUARDRAIL"

Delivery IS the execution of an outbound message; lifecycle events (trigger started, trigger completed, bulk submitted, bulk refused, etc.) are guardrails around it.

Payload content exclusion (D-03 / mirrors D17-02)

Metadata carries only attribution references (IDs, atom enums). No rendered body, no message content, no refused-reason free-text ever crosses the telemetry boundary through this module.

Metadata SHAPE (always present):

  • "openinference.span.kind" — string key per OI spec
  • :bulk_envelope_id — attribution ref (UUID string) or nil
  • :conversation_id — attribution ref (integer) or nil (bulk-envelope-scoped events)
  • :template_id — attribution ref (string)
  • :actor_id — attribution ref (string) or nil (system-initiated events)
  • :outcome — atom enum

Metadata SHAPE (:bulk_refused only, per RESEARCH OQ3):

  • :effective_cap — integer (the cap-of-the-moment) for OI consumers correlating refusals against policy changes. Low cardinality, useful attribution detail.

Fail-closed guard clause

Unknown event atoms (anything not in @events) return :ok silently — emit/2 does not call :telemetry.execute/3. The caller is never penalised for passing an atom not in the whitelist.

Summary

Functions

Emits an OI-conformant trace telemetry event for the outbound lane.

Functions

emit(event, attrs)

Emits an OI-conformant trace telemetry event for the outbound lane.

Only accepts events in @events (the 7-atom whitelist enumerated in the moduledoc). Unknown events are silently dropped (fail-closed guard clause).

Event path: [:cairnloop, :outbound, :trace, event] — 4 segments with :trace in position 3, disjoint from the bounded-metrics outbound paths (D-03).

Measurements are always %{count: 1} — callers do not supply measurements.

Metadata is built by build_metadata/2: attribution refs only, no payload content.