Skip to main content
Version: Next 🚧

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)​

pip install "cubepi[tracing]" opentelemetry-exporter-otlp-proto-http
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)​

pip install "cubepi[tracing]" opentelemetry-exporter-otlp-proto-grpc
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:

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

Grafana Tempo​

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

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

Honeycomb​

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:

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:

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:

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:

# (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).