---
title: OTLP & Backends
sidebar_position: 3
---

# Exporting to OTLP Backends

`cubepi.tracing.Tracer` accepts any `opentelemetry.sdk.trace.export.SpanExporter`,
so anything in the OpenTelemetry ecosystem works. Pick the wire format
(HTTP or gRPC), point it at your collector, hand the exporter to the Tracer.

## HTTP (OTLP/HTTP)

```bash
pip install "cubepi[tracing]" opentelemetry-exporter-otlp-proto-http
```

```python
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
    OTLPSpanExporter,
)
from cubepi.tracing import Tracer

tracer = Tracer(
    service_name="my-bot",
    service_version="1.4.2",
    deployment_environment="prod",
    agent_name="assistant",
    exporters=[
        OTLPSpanExporter(
            endpoint="http://otel-collector:4318/v1/traces",
            headers={"x-api-key": "…"},  # backend-specific
        ),
    ],
)
```

`service_name`, `service_version`, `deployment_environment`, and `agent_name`
flow through as OTel Resource attributes (`service.*`, `gen_ai.agent.name`,
`deployment.environment.name`) so backends can group runs without further
config.

## gRPC (OTLP/gRPC)

```bash
pip install "cubepi[tracing]" opentelemetry-exporter-otlp-proto-grpc
```

```python
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
    OTLPSpanExporter,
)

exporter = OTLPSpanExporter(endpoint="otel-collector:4317", insecure=True)
tracer = Tracer(service_name="my-bot", exporters=[exporter])
```

## Backend recipes

These all consume OTLP — the only thing that differs is the endpoint and
auth header.

### Jaeger (>=1.50)

Jaeger natively accepts OTLP/HTTP on port 4318:

```python
OTLPSpanExporter(endpoint="http://jaeger:4318/v1/traces")
```

### Grafana Tempo

Send to your collector, or directly to Tempo's OTLP endpoint:

```python
OTLPSpanExporter(endpoint="http://tempo:4318/v1/traces")
```

### Honeycomb

```python
OTLPSpanExporter(
    endpoint="https://api.honeycomb.io/v1/traces",
    headers={"x-honeycomb-team": HONEYCOMB_API_KEY},
)
```

### Datadog (via the OTel collector)

Configure the collector with the Datadog exporter, then ship to it:

```python
OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces")
```

Datadog also accepts native OTLP HTTP directly — same shape, different URL.

### AWS X-Ray (via collector)

The OTel collector includes the AWS X-Ray exporter; treat it like any other
OTLP target.

## Continuing an upstream trace

`Tracer.attach(agent)` currently roots every agent run in its own trace —
there is no public API yet for passing an inbound `traceparent` so that
spans nest under a caller's HTTP handler trace. The internal hook
(`Tracer._make_parent_context`) exists for a future `run_scope` feature;
until that ships, agent runs and the surrounding service trace are linked
only by their resource attributes (`service.name`, `gen_ai.agent.name`,
`cubepi.run_id`).

If you need the upstream trace to continue into cubepi today, the
workaround is to set the OTel current span yourself before calling
`agent.prompt(...)` and let the agent's spans inherit it via OTel's
ambient context — cubepi never overrides an active parent.

On the way out, MCP `tools/call` does inject W3C `traceparent` as an HTTP
header automatically, so an instrumented MCP server downstream of the
agent can continue the trace through to its own backend.

## Combining exporters

You can pass multiple exporters and they'll receive every span. Common
pattern — JSONL for local debugging plus OTLP for the production backend:

```python
tracer = Tracer(
    service_name="my-bot",
    exporters=[
        JsonlSpanExporter(directory="./cubepi-traces"),
        OTLPSpanExporter(endpoint="https://api.honeycomb.io/v1/traces", headers={…}),
    ],
)
```

## Flushing

`Tracer` uses `BatchSpanProcessor` under the hood, so spans are exported in
the background. The recommended pattern — and the one that exercises all
the cleanup paths automatically — is the `async with` form:

```python
async with Tracer(...) as tracer, tracer.attached(agent):
    await agent.prompt("...")
# On exit:
#   - detach() runs synchronously: closes any spans a cancelled run left
#     open, then schedules the flush as an asyncio.Task and awaits it.
#   - tracer.shutdown() flushes again (idempotent) and closes exporters.
```

If you've stored `detach` from a manual `tracer.attach(agent)` call, it
returns the scheduled flush `Task`. Two valid manual patterns:

```python
# (a) Belt-and-braces — safest, both run.
finally:
    detach()                    # closes cancelled-run spans, schedules flush
    await tracer.shutdown()     # awaits force_flush, then shuts the SDK down

# (b) Awaited detach — single call when you're not also shutting down.
finally:
    await detach()              # awaits the scheduled flush
```

Outside an async context (no running loop) `detach()` returns `None` — the
sync part has run, but the flush is the caller's responsibility via
`await tracer.shutdown()`.

If even that gets missed — say a script raises before reaching `finally` —
`Tracer(atexit_flush=True)` (the default) registers a process-exit handler
that sync-flushes buffered spans through `BatchSpanProcessor`. Doesn't run
on `SIGKILL` / `os._exit`; for guaranteed delivery there, use
`SimpleSpanProcessor` (sync export per span).
