Skip to content

GenAI observability (OpenTelemetry semantic conventions)

ExaMLOps emits OpenTelemetry GenAI-semconv spans from its LLM paths, reusing the existing OTLP → Tempo pipeline. This gives per-request token counts, derived cost and carbon, tool-call success/failure, and trace ids that join to eval, feedback, drift, and lineage.

Where instrumentation runs: all three boundaries ADR 0006 names.

Boundary How Spans
B2 gateway gateway/__init__.py, every routed request chat
Serving path engines/instrumented.py wraps every engine build_engine returns, so exa models engine, a serving replica and a directly-held engine are all covered without knowing it model / chat
Skipper skipper/genai_trace.py, a LangChain callback handler attached to every turn chat per model call, tool per tool call

Skipper's spans and AgentOps are complementary, not duplicates: AgentOps records tool outcomes durably in agent_tool_calls (feeding tool_success_rate and the circuit-breaker), while these spans record timing to the trace backend. The callback handler is used precisely because a ToolMessage carries no start time — a span opened at the AgentOps sink would report a duration of zero, and latency is most of what a tool span is for.

Design: ADR 0006 · spec design/vision/specs/C1-otel-genai-semconv.md. Pinned to OpenTelemetry GenAI semconv 1.27.0 (genai.SEMCONV_VERSION).

What gets emitted

Each LLM call emits a model span (chat at the gateway and on chat-native engines) carrying gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens and gen_ai.response.finish_reasons — and no usage attribute at all when the provider reported none, because a zero token count and an unreported one are different facts. Every span also carries ExaMLOps extras — examlops.tenant, examlops.request_hash, examlops.model.alias/version and a derived examlops.cost.usd — so a span joins to the eval loop, drift baselines, FinOps and the A2 lineage graph. The RAG retriever emits a tool span.

Latency is the span's own duration. Serving-path spans enclose the call, so their duration is the model's latency. The gateway's span is opened after its completion returns, so read gateway latency from the enclosing request span, not from the chat span itself.

A stream reports examlops.stream.chunks, never a token count. The engine stream surfaces yield text fragments with no usage block; publishing a chunk tally under a token attribute would be a guess wearing a standard name.

Carbon (Green-AI, ADR 0006 clause 4)

A span from an engine that owns its hardware for the duration it measured also carries examlops.energy.kwh, examlops.carbon.co2e_g and examlops.carbon.provider, computed by the same pluggable provider exa finops carbon uses — one methodology, not a second hard-coded formula.

Device-hours are an input, never a guess, so two cases deliberately carry no carbon attribute: a server-mode engine (vllm-server), whose GPU is continuously batching other clients — charging each one its own wall-clock would count one accelerator many times over, and that server reports its own utilization — and the gateway, which does not own the hardware behind a routed backend at all. A CPU-only inference is accounted: it burned energy, and the Green-AI model has a CPU term for it.

Which conventions get emitted

The GenAI conventions are still Development upstream, so this instrumentation pins 1.27.0 and honours OpenTelemetry's opt-in rather than chasing renames. Setting OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental moves captured content onto the structured gen_ai.input.messages / gen_ai.output.messages attributes; without it the flat gen_ai.prompt / gen_ai.completion attributes keep being emitted. Every span reports which set it used in examlops.semconv.version.

Note the GenAI area has no <area>/dup dual-emit token — unlike the HTTP conventions, OTel defines one value that replaces the pinned set. Content is therefore emitted under one shape or the other, never both: capture is the single place content leaves the process, and writing the same redacted text twice would double the exposure the privacy gate bounds.

Privacy — content is off by default

Prompt and completion text are never captured unless you explicitly set EXAMLOPS_GENAI_CAPTURE_CONTENT=true. Even then, content passes through the D8 redaction hook (PII defense) before export. Treat enabling capture as a governed action.

Toggles

Variable Default Effect
OTEL_SDK_DISABLED true Master switch. When truthy/unset, all GenAI instrumentation is a no-op — zero overhead, behaviour unchanged.
EXAMLOPS_GENAI_CAPTURE_CONTENT unset When truthy, capture (redacted) prompt/completion content on spans.
OTEL_SEMCONV_STABILITY_OPT_IN unset Comma-separated OTel opt-in. gen_ai_latest_experimental selects the structured message attributes.
OTEL_EXPORTER_OTLP_ENDPOINT http://tempo:4317 Where spans are exported.

CLI

exa genai check                                   # tracing on/off, capture, semconv version
exa genai cost --model gpt-4o --in 1200 --out 300 # estimate USD cost from token usage
exa --json genai check                            # machine-readable

Instrumenting a call (library)

from examlops.telemetry import genai

with genai.genai_span("model", system="openai", model="gpt-4o",
                      tenant="acme", request_hash=req_hash) as span:
    resp = call_the_model(...)
    genai.record_usage(span, model="gpt-4o",
                       input_tokens=resp.usage.in_, output_tokens=resp.usage.out,
                       finish_reasons=["stop"])
    genai.maybe_capture_content(span, prompt=prompt, completion=resp.text)  # gated + redacted

When tracing is disabled the context manager yields a no-op span, so the same code runs unchanged in dev, tests, and the non-monitoring stack.

Dashboards

The monitoring stack ships GenAI Grafana panels (tokens/s, $/request, TTFT, tool success-rate) under platform/infra/docker-compose/grafana/provisioning/dashboards/.