Problem Context

OpenTelemetry is the lingua franca for traces, metrics, and logs. The gen_ai.* semantic conventions extend it specifically for LLM workloads โ€” token counts, costs, model versions, finish reasons, tool calls. Adopting them now means your traces stay portable across Phoenix, Langfuse, Datadog, App Insights, and whatever you migrate to next.

๐Ÿค” Sound familiar?
  • You have vendor-locked traces in one tool and can't correlate with your APM
  • You're hand-rolling span names like "openai_call_v2" and have 14 variants
  • You want one trace for the whole request โ€” gateway โ†’ retrieval โ†’ LLM โ†’ tool โ€” but your LLM client doesn't join the trace

The Stack

  • SDK โ€” @opentelemetry/api, opentelemetry-sdk-node, opentelemetry-python.
  • Instrumentation โ€” opentelemetry-instrumentation-openai, ...-anthropic, ...-langchain, plus auto-instrumentation for HTTP/DB.
  • Collector โ€” the OTel Collector receives OTLP, transforms, and fans out to backends.
  • Backend โ€” Phoenix, Langfuse, Tempo, Jaeger, App Insights, Datadog. All consume OTLP.

flowchart LR
    APP[App + auto-instrumentation] -->|OTLP/gRPC| C[OTel Collector]
    C --> PH[Phoenix]
    C --> AI[App Insights]
    C --> DD[Datadog]
    C --> SQL[(Long-term store)]
      

The Conventions You Should Emit

Attributes from the gen_ai semantic conventions (2025+):

gen_ai.system               "openai" | "azure.ai.openai" | "anthropic" | "google.gemini"
gen_ai.operation.name       "chat" | "embeddings" | "completion"
gen_ai.request.model        "gpt-4o-2024-08-06"
gen_ai.request.temperature  0.2
gen_ai.request.max_tokens   1024
gen_ai.response.model       "gpt-4o-2024-08-06"
gen_ai.response.finish_reasons ["stop"]
gen_ai.usage.input_tokens   1248
gen_ai.usage.output_tokens  312
gen_ai.usage.cached_tokens  1024

Add your own under a dedicated namespace (e.g. app.tenant.id, app.prompt.template.id) โ€” don't collide with reserved names.

Minimal Node.js Setup

import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OpenAIInstrumentation } from '@traceloop/instrumentation-openai';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

const sdk = new NodeSDK({
  serviceName: 'support-agent',
  traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT }),
  instrumentations: [
    getNodeAutoInstrumentations(),
    new OpenAIInstrumentation({ captureMessageContent: false }), // PII!
  ],
});

await sdk.start();

One Trace, End-to-End

The win is propagation. The HTTP server creates the root span; auto-instrumented Postgres, Redis, and HTTP clients add child spans; the LLM instrumentation adds the gen_ai span. As long as the same trace context flows through (via traceparent headers or async-local storage), you get a single tree from request to response โ€” including the embedding lookup and the tool call.

Capturing Messages โ€” Carefully

Most instrumentations have a captureMessageContent flag. Turning it on records prompts and completions as span events, which makes debugging trivial โ€” and gives you a giant pile of PII. The pragmatic settings:

  • Off in production by default.
  • On for an internal dogfooding tenant or a feature flag for opted-in users.
  • If on, redact: e-mail, phone, card numbers via a Collector processor before storage.

The Collector Is Your Friend

Run the OTel Collector as a sidecar or DaemonSet. It centralises:

  • Sampling decisions (tail-based, error-priority).
  • Redaction processors (regex over span attributes).
  • Cost computation (tokens ร— price) injected as gen_ai.cost.usd.
  • Multi-backend fan-out โ€” switch vendors by editing one YAML.

Failure Modes

  • Broken context propagation โ€” async tasks lose the active span. Use the framework's context manager (context.with, FastAPI middleware) or AsyncLocalStorage.
  • Cardinality explosions โ€” putting user.id on a metric attribute kills your metrics backend. Keep high-cardinality on spans, low-cardinality on metrics.
  • Streaming LLM responses โ€” a span closed at first byte misses the full output. Use the streaming-aware version of the instrumentation.
  • Custom span names per call โ€” "openai-call-42" defeats aggregation. Use the operation name (chat) and let attributes carry the detail.

Production Checklist

  • OTel SDK + auto-instrumentation + a GenAI instrumentation per provider you use.
  • Collector as sidecar/DaemonSet with redaction + cost processors.
  • gen_ai.* conventions only โ€” no bespoke attribute names.
  • Sampling: 100% on error/feedback, tail-based on the rest.
  • traceparent threaded through every internal HTTP/queue hop.

Key Takeaways

  • OpenTelemetry + gen_ai.* is the portable choice โ€” pick it before you pick a backend.
  • The Collector is where you do redaction, sampling, and cost โ€” keep app code dumb.
  • One trace from gateway to LLM to tool is the artifact that makes everything else (eval, replay, debug) possible.