Docs / Instrument · OpenTelemetry

OpenTelemetry

Dunetrace speaks OpenTelemetry in both directions. Export agent runs as OTLP spans to whatever backend you already run, or point an existing OTel pipeline at Dunetrace so the structural detectors run with no manual instrumentation. Dunetrace's own event schema stays canonical either way — OTel is a bridge, not a replacement.

How it works

There are two independent directions, and you can use either, both, or neither:

Direction 1 — Export:   Dunetrace SDK ──► OTLP spans ──► Datadog / Grafana / Honeycomb / Signoz
Direction 2 — Receive:  your OTel tracer ──► gen_ai.* spans ──► Dunetrace detectors ──► dashboard
  • Export is additive: the same events still ship to Dunetrace's own ingest, and a copy is emitted as OpenTelemetry spans so a run shows up in your existing tracing backend too.
  • Receive is for agents already instrumented with an OTel-based tracer (OpenLLMetry, Traceloop, OpenLIT) or a no-code platform that emits OTLP — Dunetrace translates the spans into events and runs the full detector suite, no dt.run() calls required.
Spans follow the current OpenTelemetry GenAI semantic conventions — provider is emitted as gen_ai.provider.name (not the deprecated gen_ai.system), and HTTP-shaped tool calls use the stable HTTP conventions (url.full, server.address, http.response.status_code).

Prerequisites

  • Dunetrace backend running (docker compose up -d)
  • Python: pip install 'dunetrace[otel]' — pulls in the OpenTelemetry SDK and OTLP exporter
  • TypeScript: npm install @opentelemetry/api @opentelemetry/sdk-trace-base alongside dunetrace (optional peer dependencies)

Direction 1 · Export Dunetrace events to your OTel backend

Python — env-driven (zero code)

When DUNETRACE_OTEL_ENABLED is set, the client builds the exporter automatically. Export runs on a bounded background pipeline with a circuit breaker, so a slow or dead collector never blocks the agent thread or raises into your code.

export DUNETRACE_OTEL_ENABLED=1
export DUNETRACE_OTEL_ENDPOINT=https://otlp.example:4317
export DUNETRACE_OTEL_HEADERS="DD-API-KEY=xxxxx"   # provider auth, "k1=v1,k2=v2"
export DUNETRACE_OTEL_PROTOCOL=grpc                # or http/protobuf
VariableDefaultDescription
DUNETRACE_OTEL_ENABLEDoff1/true turns export on
DUNETRACE_OTEL_ENDPOINTOTLP endpoint URL (required when enabled)
DUNETRACE_OTEL_HEADERSAuth headers, k1=v1,k2=v2
DUNETRACE_OTEL_PROTOCOLgrpcgrpc or http/protobuf
DUNETRACE_OTEL_SERVICE_NAMEdunetraceservice.name resource attribute
DUNETRACE_OTEL_SAMPLING_RATIO1.0Head sampling ratio, 0.0–1.0
DUNETRACE_OTEL_CAPTURE_CONTENTtrueInclude content attributes (tool args, URL, retrieval query)

Python — programmatic (your own TracerProvider)

from opentelemetry.sdk.trace import TracerProvider
from dunetrace import Dunetrace
from dunetrace.integrations.otel import DunetraceOTelExporter

provider = TracerProvider()   # your pipeline, with whatever exporters you like
dt = Dunetrace(otel_exporter=DunetraceOTelExporter(tracer_provider=provider))

TypeScript

import { Dunetrace } from "dunetrace";
import { DunetraceOtelExporter } from "dunetrace/integrations/otel";
import { trace } from "@opentelemetry/api";

const dt = new Dunetrace({
  exporter: new DunetraceOtelExporter({ tracer: trace.getTracer("dunetrace") }),
});

Every emitted event is also handed to the exporter. It never throws — a failure in the exporter is caught and never touches the agent's own event path.

What gets exported

Trace (trace_id derived from run_id, so a Dunetrace run is exactly one trace)
└── span "dunetrace.run"              [dunetrace.run.id, .agent_id, .status, ...]
    ├── span "chat {model}"           [gen_ai.provider.name, gen_ai.request.model,
    │                                  gen_ai.usage.input_tokens/output_tokens, ...]
    ├── span "dunetrace.tool.{name}"  [url.full / server.address / http.request.method if HTTP]
    ├── span "dunetrace.retrieval"    [dunetrace.retrieval.vector_store, .document_count]
    ├── span "dunetrace.voice.transcription" / ".tts"
    └── event "dunetrace.voice.{vad}" [silence / speech_start / barge_in] on the run span

Direction 2 · Send your OpenTelemetry traces to Dunetrace

Option A — Zero-code OTLP/HTTP endpoint

If your agent or platform already emits OpenTelemetry traces, point its exporter at Dunetrace's receiver — no SDK, no code change.

POST http://<dunetrace-host>:8001/v1/otlp/traces
Authorization: Bearer <api_key>      # optional in dev mode

The endpoint accepts both application/json and application/x-protobuf (the default for most OTel Collectors and OTLPSpanExporter), gzip-compressed or not.

from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource

provider = TracerProvider(resource=Resource.create({"service.name": "my-agent"}))
provider.add_span_processor(BatchSpanProcessor(
    OTLPSpanExporter(
        endpoint="http://localhost:8001/v1/otlp/traces",
        headers={"Authorization": "Bearer YOUR_API_KEY"},
    )
))

No-code platforms (Langdock, Dify, and similar): set the OTLP/tracing endpoint in your workspace settings. Cloud platforms cannot reach localhost — use a tunnel (ngrok) or your deployed host.

Option B — In-process receiver (Python)

Already running the Dunetrace SDK next to an OTel pipeline? Attach the receiver as a second span processor — same result as the HTTP endpoint, without leaving the process.

from opentelemetry.sdk.trace import TracerProvider
from dunetrace import Dunetrace
from dunetrace.integrations.otel_receiver import DunetraceOTelReceiver

dt = Dunetrace(api_key="dt_live_...")
provider = TracerProvider()                                  # your existing pipeline, unchanged
DunetraceOTelReceiver.attach(provider, dt, agent_id="my-agent")

Option B — In-process receiver (TypeScript)

import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { Dunetrace } from "dunetrace";
import { DunetraceOtelReceiver } from "dunetrace/integrations/otel-receiver";

const dt = new Dunetrace({ apiKey: "dt_live_..." });
const provider = new NodeTracerProvider({
  spanProcessors: [new SimpleSpanProcessor(new DunetraceOtelReceiver(dt, "my-agent"))],
});
provider.register();

Attributes read

The receiver reads the current GenAI semantic conventions first, then the OpenLLMetry / vector-store keys real emitters (OpenLIT, Traceloop) also use — including Traceloop's structured gen_ai.output.messages and the legacy prompt_tokens / completion_tokens naming.

Span kindDetected byEmits
Root (no parent)run.started + run.completed / run.errored
LLMgen_ai.request.model, gen_ai.provider.name, llm.request.modelllm.called + llm.responded
Toolgen_ai.tool.name, tool.nametool.called + tool.responded
Retrievalretrieval.index_name, vector_db.collection_name, db.nameretrieval.called + retrieval.responded

Agent identity comes from the service.name resource attribute (override per-request with the X-Dunetrace-Agent-Id header on the HTTP endpoint, or the constructor label on the in-process receiver). All detectors activate automatically on each completed trace.

Semantic conventions

  • LLM spans follow the OpenTelemetry GenAI conventions (gen_ai.*). Provider is gen_ai.provider.name, tokens are gen_ai.usage.input_tokens / output_tokens, and finish reasons are gen_ai.response.finish_reasons (an array).
  • HTTP-shaped tool calls use the current stable HTTP conventions (url.full, server.address, http.request.method, http.response.status_code), not the deprecated http.url / http.method / http.status_code.
  • Agent runs, tools, retrievals, and voice have no standard OTel operation type, so those fields are namespaced under dunetrace.*.

PII and content capture

Content-bearing attributes (tool arguments, request URL, retrieval query) are emitted only when content capture is on — the default. Disable it with DUNETRACE_OTEL_CAPTURE_CONTENT=false (Python) or new DunetraceOtelExporter({ captureContent: false }) (TypeScript). Voice spans never carry raw transcript or TTS text, only character counts and metadata.

Correlation

A run's trace id is derived deterministically from its run_id (the UUID as a 128-bit integer), and the root span id is its lower 64 bits. A Dunetrace run therefore maps to one stable, addressable trace that both the Dunetrace dashboard and your backend can deep-link to. A child run (with a parent) gets its own trace plus a span link back to the parent run's root span — the OTel-correct way to express causally related spans across traces.

Troubleshooting

Nothing appears in my OTel backend

  • Confirm DUNETRACE_OTEL_ENABLED is set and DUNETRACE_OTEL_ENDPOINT is reachable from the agent host.
  • Check the protocol matches your collector: grpc (port 4317) vs http/protobuf (port 4318).
  • Export failures are swallowed by design — enable debug logging on the dunetrace.otel logger to see circuit-breaker events.

Traces arrive but no signals fire

  • Signals are decided on the Dunetrace side after a trace completes — confirm the root span reaches the receiver (it closes the run).
  • Check the span attributes match the keys in the "Attributes read" table; a span with none of them is treated as a lifecycle span and skipped.