Send traces to Langfuse, Phoenix or OpenLLMetry¶
OTelSink turns the executor's events into OpenTelemetry spans that follow the GenAI
semantic conventions. Any backend that takes OTLP can show them. You pick the exporter;
the adapter needs no backend-specific code.
flowchart LR
E["Executor<br/>tools · model calls · flows"] -->|events| S["OTelSink"]
S -->|"GenAI spans"| P["TracerProvider<br/>(opentelemetry-sdk)"]
P -->|"OTLP/HTTP"| L["Langfuse"]
P -->|"OTLP/HTTP"| X["Phoenix"]
P -->|"global provider"| O["OpenLLMetry backend"]
Install¶
The otel extra brings only opentelemetry-api. agent-adapter doctor reports
otel export as ready once the SDK and an OTLP exporter are installed.
1. Build a provider for your exporter¶
def otlp_provider(exporter: Any, service: str = "shop-agent") -> Any:
"""A tracer provider that batches spans to `exporter`; needs opentelemetry-sdk."""
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
provider = TracerProvider(resource=Resource.create({"service.name": service}))
provider.add_span_processor(BatchSpanProcessor(exporter))
return provider
Then give the sink to the executor. Every tool call and model call becomes a span:
executor = Executor(registry, sink=sink)
model = executor.model(CallableModel(answer, provider="house", model="house-7b"))
async with executor.run_scope(run_id="run_shop") as ctx:
await executor.run("stock", {"item": "mug"}, ctx)
ask = LLMRequest(messages=(Message(role="user", content="How many mugs?"),))
await model.bind(ctx).generate(ask)
run_once(OTelSink(tracer_provider=otlp_provider(exporter))) sends one run. Call
provider.shutdown() before the process exits so the batch is flushed.
2. Langfuse¶
def langfuse_exporter(public_key: str, secret_key: str, host: str) -> Any:
"""Langfuse's OTLP endpoint (HTTP only), authenticated with the project's key pair."""
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
token = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode()
return OTLPSpanExporter(
endpoint=f"{host.rstrip('/')}/api/public/otel/v1/traces",
headers={"Authorization": f"Basic {token}"},
)
# host: "https://cloud.langfuse.com" (EU), "https://us.cloud.langfuse.com" (US), or your own
Langfuse takes OTLP over HTTP only, not gRPC. The public and secret keys come from the project settings; keep them in environment variables or a secret store.
3. Arize Phoenix¶
def phoenix_exporter(endpoint: str = "http://localhost:6006/v1/traces") -> Any:
"""A Phoenix server's OTLP/HTTP collector (the default port of a local Phoenix)."""
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
return OTLPSpanExporter(endpoint=endpoint)
A local Phoenix listens on port 6006. For a hosted Phoenix, pass its collector URL and
add the API key header its settings page shows (headers={...} on the exporter).
4. OpenLLMetry¶
def openllmetry_sink(app_name: str = "shop-agent") -> OTelSink:
"""OpenLLMetry (traceloop-sdk) sets the global provider; the sink then writes to it."""
from traceloop.sdk import Traceloop
Traceloop.init(app_name=app_name) # its own exporter settings: TRACELOOP_* variables
return OTelSink() # no tracer_provider: the global one
OpenLLMetry (traceloop-sdk) installs a global tracer provider and its own exporter.
OTelSink() with no provider writes to that global one, so our spans and OpenLLMetry's
land in the same traces. If OpenLLMetry also instruments the SDK your model port calls,
each model call shows twice: once from it and once from us. Turn off its instrumentation
for that SDK, or keep both on purpose.
What the spans carry¶
| Span | Attributes |
|---|---|
chat {model} (generate_content for Gemini) |
gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.response.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, cache and reasoning tokens |
execute_tool {tool} |
gen_ai.tool.name, gen_ai.tool.call.id, gen_ai.tool.type |
invoke_workflow {flow}, node {flow}.{node} |
gen_ai.workflow.name, agent_adapter.flow.* |
| every span | gen_ai.conversation.id = the run id; agent_adapter.* extras such as cost, stage and cache hits on model spans |
Convention version. Names follow the OpenTelemetry GenAI semantic conventions as
shipped in opentelemetry-semantic-conventions 0.66b. The conventions are still in
Development, so a backend may label some fields differently. Every name lives in
agent_adapter/tracing/semconv.py.
Content stays off. Prompts, replies, tool arguments and results are not exported
unless you pass OTelSink(capture_content=True). Turn it on only where the backend may
hold that data.
Output¶
The example runs offline with an in-memory exporter in place of OTLP:
Without opentelemetry-sdk it prints opentelemetry-sdk not installed.
Files¶
| File | Does |
|---|---|
agent_adapter/tracing/otel.py |
OTelSink: events in, GenAI spans and metrics out |
agent_adapter/tracing/semconv.py |
every attribute, span and metric name |
examples/traces_otlp.py |
providers and exporters for each backend; one traced run |
tests/tracing/test_traces_otlp.py |
runs the example and checks the span attributes |
More on spans, parents and metrics: Tracing.