> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ctrlrun.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Export to OpenTelemetry

> Add OTelEventSink to a Control and every action becomes one span with one span event per step, in whatever tracing backend your process already exports to.

`OTelEventSink` turns every action into one OpenTelemetry span, named for the action, with one
span event per step: proposed, decided, reserved, executed, recorded. It hands spans to whatever
tracer provider your process configured and never blocks. Argument values are not attributes
unless you ask, because a trace backend is not the receipt store.

**Prerequisites:** `pip install "ctrlrun[otel]"`, which brings the API, the SDK and the
OTLP/HTTP exporter, and a backend that speaks OTLP (or, below, an in-memory exporter to see
the spans without one).

<Steps>
  <Step title="Attach the sink">
    ```yaml runnable theme={null}
    schema: ctrlrun.policy/v2

    actions:
      crm.update_record:
        effect: "crm:{record_id}:{field}"
        decision: allow
    ```

    ```python runnable theme={null}
    from opentelemetry.sdk.trace import TracerProvider
    from opentelemetry.sdk.trace.export import SimpleSpanProcessor
    from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

    import ctrlrun
    from ctrlrun import Control, Policy, SQLiteStateStore
    from ctrlrun.otel import OTelEventSink

    exporter = InMemorySpanExporter()
    provider = TracerProvider()
    provider.add_span_processor(SimpleSpanProcessor(exporter))

    control = Control(
        Policy.from_file("ctrlrun.yaml"),
        SQLiteStateStore(".ctrlrun/state.db"),
        sinks=[OTelEventSink(tracer_provider=provider)],
    )


    @ctrlrun.protect("crm.update_record", effect="crm:{record_id}:{field}", control=control)
    def update(record_id: str, field: str, value: str) -> str:
        return "updated"


    with ctrlrun.context(agent="crm-agent"):
        update(record_id="c_42", field="phone", value="+353 1 555 0100")

    (span,) = exporter.get_finished_spans()
    print("span:", span.name)
    print("events:", [event.name for event in span.events])
    print("arguments in attributes:", any("phone" in str(v) for v in span.attributes.values()))
    ```

    ```text theme={null}
    span: crm.update_record
    events: ['ACTION_PROPOSED', 'POLICY_EVALUATED', 'EFFECT_RESERVED', 'EXECUTION_STARTED', 'EXECUTION_COMMITTED']
    arguments in attributes: False
    ```
  </Step>

  <Step title="Export for real">
    Replace the in-memory exporter with the OTLP one your backend expects, or rely on the
    provider your application already configured and pass nothing: with no `tracer_provider`
    the sink uses the global one, and with none configured the API's no-op provider makes the
    sink free.

    ```python theme={null}
    from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
    from opentelemetry.sdk.trace.export import BatchSpanProcessor

    provider = TracerProvider()
    provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))   # OTEL_EXPORTER_OTLP_ENDPOINT
    ```

    With the gateway, `--otel` does the same, and `--otel-arguments` opts argument values in.
  </Step>

  <Step title="Read the span">
    The span's status is an error for `failed` and `ambiguous`, unset for a refusal (a refusal
    is CTRLRun doing its job, not an error), and ok for `committed`. Attributes carry the action
    name, the decision, the effect key, the outcome and the receipt id; the receipt itself stays
    in the store. Open spans are bounded, so a process that dies mid-action leaves at most a
    fixed number unended, stated rather than solved.
  </Step>
</Steps>

## What the sink is not

It is not the evidence. Receipts live in the store and the JSONL file, are chained, and are
what `ctrlrun receipts --verify-chain` checks; a trace is a view of them for the people who
already look at traces. Deleting a trace deletes nothing CTRLRun relies on.

## If it didn't work

* `MissingDependency: pip install "ctrlrun[otel]"`: the extra is not installed.
* No spans arrive: no span processor is attached to the provider the sink was given, or the
  batch processor has not flushed yet; `provider.force_flush()` before exit.
* Argument values appear: `arguments=True` was passed, or `--otel-arguments` on the gateway.

## Next

* [Receipts and evidence](/concepts/receipts-and-evidence).
* [Receipt and event schemas](/reference/receipt-and-event-schemas): the event names above.
* [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [Receipts into OpenTelemetry](/cookbook/receipts-to-opentelemetry.md)
- [Receipts and evidence](/concepts/receipts-and-evidence.md)
- [OTelEventSink](/reference/api/otel-OTelEventSink.md)
- [Roadmap](/ROADMAP.md)
- [Cookbook](/cookbook/index.md)
