Skip to content

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

pip install "agent-adapter[otel]" opentelemetry-sdk opentelemetry-exporter-otlp-proto-http

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:

chat house-7b chat run_shop
execute_tool stock execute_tool run_shop

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.