Skip to content

OTLP wire contract

First, some vocabulary. OpenTelemetry (OTel) is a standard for emitting traces from running software. OTLP is the wire protocol OTel uses to ship those traces over the network. A trace is made of spans. A span is one timed unit of work (a tool call, an LLM request). Each span carries attributes, which are key/value pairs that describe it.

xray ingests your agent's OpenTelemetry traces through one endpoint. It turns recognized spans into structured tool_calls / model_usage rows. This page is the contract. It covers three things:

  • what the endpoint accepts,
  • how a span is routed to a Replay, and
  • which span shapes are recognized.

It's derived from src/server/otlp/.

You don't have to emit xray-specific spans. Maybe your agent is already instrumented with the OTel GenAI semantic conventions (gen_ai.*) or with Langfuse. If so, it lights up automatically. The xray.* vocabulary is optional and additive.


Endpoint

PathPOST /v1/otlp/v1/traces
Content typesapplication/json and application/x-protobuf (both standard OTLP ExportTraceServiceRequest).
Success200 with { "partialSuccess": { "rejectedSpans": N } }.

xray.attach's exporter posts OTLP/JSON. The stock OTel HTTP exporter defaults to protobuf, and that works too. A Content-Type with parameters (application/json; charset=utf-8) is matched correctly.

Limits

CapValueBehaviour on exceed
Body size4 MiB413 body_too_large.
Spans per request512400 too_many_spans_per_request. The whole request is rejected.
Spans per replay5,000Spans over the cap are counted in rejectedSpans; in-cap spans in the same batch still persist. No error.

Other failures map to one of these:

  • 400 invalid_otlp_body (malformed or schema-invalid body),
  • 415 unsupported_content_type, or
  • 500 internal_error.

Idempotency

Spans are de-duplicated on (replay_id, span_id). Re-sending a span that is already stored is a no-op. It isn't re-counted against the cap. Its extracted rows aren't re-processed. So a batch is safe to retry.


Routing and the trust boundary

Every span is routed to a Replay by the xray.replay.id attribute. The value can sit in two places. A span-level value takes precedence. The resource-level value is the fallback. (attach sets it as baggage. The span processor lifts that baggage onto every span. So in practice the value is present at the span level.)

The receiver is a filter, not a gate. A span is silently dropped (counted in rejectedSpans, never an error) in three cases:

  1. it carries no xray.replay.id (no replay context, for example the agent running in production);
  2. the xray.replay.id names a Replay that doesn't exist; or
  3. its vocabulary isn't recognized.

This is the trust boundary: the OTLP receiver never creates Conversation or Replay rows. It only reads existing ones. Replay rows are created exclusively by the SDK control plane. That happens before the agent emits its first span. That ordering is what makes "unknown replay id, so drop" safe rather than lossy.

Timestamps

startTimeUnixNano / endTimeUnixNano are converted to ISO-8601. They are stored as each row's started_at / ended_at. These feed the audio-timeline turn attribution described below.


The three vocabularies

A vocabulary is a set of rules for recognizing one family of spans. Each span is run through an ordered registry of vocabularies. The first one that recognizes the span wins. The order is fixed:

  1. xray
  2. gen_ai (OTel GenAI semconv)
  3. langfuse

A vocabulary match can emit a tool_calls row, a model_usage row, or neither. But every recognized span is also stored raw in the spans table, tagged with the matching vocabulary, for the inspector's timeline.

1 · xray

This vocabulary recognizes exactly three span names. It is an exact-match set, not a prefix wildcard:

  • xray.turn
  • xray.stage.stt
  • xray.stage.tts

These land in the raw spans table only. They produce no tool_calls / model_usage rows. Turn boundaries come from server-side VAD. Assertion and judge outcomes come from the declared catalog, not from these spans. Any other xray.* name (for example xray.stage.llm) is unrecognized and dropped.

xray.assertion and xray.judge are not recognized. Evaluation runs server-side from the Assertion / Judge catalog declared on the Conversation. So driver-emitted assertion/judge spans are intentionally ignored.

2 · gen_ai (OTel GenAI semantic conventions)

This vocabulary dispatches on the gen_ai.operation.name attribute. A span also counts as GenAI if any of these is true: an attribute key starts with gen_ai., or the span name starts with chat, text_completion, or execute_tool.

execute_tooltool_calls row:

FieldFrom
namegen_ai.tool.name (fallback: span name minus the execute_tool prefix)
args_jsongen_ai.tool.arguments, else gen_ai.tool.call.arguments, else tool_arguments
result_jsongen_ai.tool.result, else gen_ai.tool.call.result, else tool_response
latency_msspan end − start

The tool-I/O fallbacks exist because instrumentations disagree on the key. pydantic-ai emits gen_ai.tool.call.arguments / gen_ai.tool.call.result on instrumentation v3+ and tool_arguments / tool_response before that (span named running tool, still gen_ai.operation.name='execute_tool'). Content is only present when the instrumentation is configured to capture it — for pydantic-ai, InstrumentationSettings(include_content=True). The first non-null key wins; the two unprefixed keys are also kept on the stored span attributes, which are otherwise narrowed to gen_ai.*.

chat or text_completionmodel_usage row:

FieldFrom
providergen_ai.system
modelgen_ai.response.model (fallback gen_ai.request.model)
input_tokens / output_tokensgen_ai.usage.input_tokens / gen_ai.usage.output_tokens
total_tokenssum of the two (null only if both absent)
ttft_msgen_ai.response.time_to_first_chunk, interpreted as seconds, converted to ms
latency_msspan end − start

Any other operation (for example embeddings) is stored as a raw gen_ai span with no extracted row.

Earlier docs referred to gen_ai.tool and gen_ai.client.operation. Those are not what the code matches on. The dispatch key is gen_ai.operation.name, with values execute_tool / chat / text_completion.

3 · langfuse

This vocabulary recognizes any span carrying an attribute with a langfuse. prefix. It reads the observation type from langfuse.observation.type (fallback langfuse.type).

generationmodel_usage row:

FieldFrom
providerlangfuse.observation.provider
modellangfuse.observation.model.name
input_tokens / output_tokenslangfuse.observation.usage_details.input / .output
total_tokenslangfuse.observation.usage_details.total (read directly)
ttft_msalways null (not sourced from Langfuse)

tooltool_calls row:

FieldFrom
namelangfuse.observation.name (fallback: span name)
args_json / result_jsonlangfuse.observation.input.value / .output.value

Other observation types (event, span, score, unset) are stored as raw langfuse spans with no extracted row.


What lands where

TableWritten forWhen
spansevery accepted spanalways, regardless of vocabulary
tool_callsgen_ai execute_tool, langfuse toola tool was observed
model_usagegen_ai chat / text_completion, langfuse generationan LLM call was observed

Turn attribution is derived, not stored

tool_calls and model_usage carry only replay_id and (nullable) span_id. There is no turn_idx column on them. A row's turn membership is not stored. It is computed at evaluation/read time. The server maps the row's wall-clock started_at onto the audio timeline:

audio_offset_ms = started_at − replays.recording_started_at

It then tests that offset against the turn windows derived from VAD. The recording_started_at origin is set by the driver's audio upload (the X-Recording-Started-At header). This OTLP path never sets it. With no anchor, the timeline-dependent assertions return errored. Those assertions are tool_called, tool_not_called, tool_args_match, and max_ttft_ms. The origin must be the audio sample-0 wall-clock (the X-Recording-Started-At header). It must never be the replay row's creation time, which precedes the recording.


Adding a vocabulary

Each vocabulary is one file in src/server/otlp/vocabularies/. The file exports a pure match(span, resource) function. You also add one line in registry.ts. Test it against synthetic projected spans with the slice's test-utils. No network is needed. See architecture.md and the contributing guide.