Agentic API can export traces and metrics to any OpenTelemetry Collector or OTLP-compatible backend. Export is off by default: with no exporter selected the gateway creates no provider, exporter thread, or network connection, and only prints local logs as before.
This page covers HTTP and execution traces, upstream context propagation, standard HTTP request metrics, trace-correlated local logs, and the export lifecycle from #279. Gateway-specific metrics remain a later phase.
Select an exporter per signal with the standard variables. Both default to
none; the OpenTelemetry specification defaults them to otlp, and this
gateway deliberately deviates so that telemetry stays opt-in.
export OTEL_TRACES_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
export OTEL_SERVICE_NAME=agentic-api
agentic-serverThe only supported transport is OTLP over HTTP/protobuf. Setting
OTEL_EXPORTER_OTLP_PROTOCOL (or a signal-specific variant) to grpc or
http/json is a startup error rather than a silent fallback: the gRPC
exporter depends on tonic 0.14, whose minimum Rust version (1.88) is above
this repository's MSRV.
OTEL_SDK_DISABLED=true overrides every other variable and disables telemetry.
Variables validated by the gateway (invalid values fail startup):
| Variable | Values | Default |
|---|---|---|
OTEL_SDK_DISABLED |
true | false |
false |
OTEL_TRACES_EXPORTER |
none | otlp |
none |
OTEL_METRICS_EXPORTER |
none | otlp |
none |
OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, OTEL_EXPORTER_OTLP_METRICS_PROTOCOL |
http/protobuf |
http/protobuf |
OTEL_EXPORTER_OTLP_COMPRESSION, OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, OTEL_EXPORTER_OTLP_METRICS_COMPRESSION |
gzip, or unset for no compression (zstd and the literal none are rejected) |
unset |
OTEL_SERVICE_NAME |
any non-empty string | agentic-api |
Variables read directly by the OpenTelemetry SDK and OTLP exporter, with the standard precedence signal-specific variable → generic variable → default:
| Variable | Purpose | Default |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT (_TRACES_, _METRICS_) |
Collector base URL; /v1/traces and /v1/metrics are appended to the generic endpoint |
http://localhost:4318 |
OTEL_EXPORTER_OTLP_HEADERS (_TRACES_, _METRICS_) |
key=value,... headers, for example vendor authentication |
none |
OTEL_EXPORTER_OTLP_TIMEOUT (_TRACES_, _METRICS_) |
Per-export request timeout in milliseconds; the effective bound on every export call | 10000 |
OTEL_RESOURCE_ATTRIBUTES |
Extra key=value resource attributes |
none |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG |
Sampler; see below | parentbased_always_on |
OTEL_BSP_MAX_QUEUE_SIZE, OTEL_BSP_SCHEDULE_DELAY, OTEL_BSP_MAX_EXPORT_BATCH_SIZE |
Span batch processor | 2048, 5000, 512 |
OTEL_METRIC_EXPORT_INTERVAL |
Periodic metric reader interval in milliseconds | 60000 |
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, OTEL_SPAN_EVENT_COUNT_LIMIT, OTEL_SPAN_LINK_COUNT_LIMIT |
Per-span record limits | 128 |
The exported resource always carries service.name and service.version
(the gateway crate version); OTEL_RESOURCE_ATTRIBUTES is merged in.
OTEL_BSP_EXPORT_TIMEOUT and OTEL_METRIC_EXPORT_TIMEOUT are accepted by the
SDK but not applied by the processors this gateway uses (SDK 0.32); set
OTEL_EXPORTER_OTLP_TIMEOUT instead. The shutdown deadlines below apply on
top of it.
Every HTTP request produces one http.server.request span, named
{method} {route} (for example POST /v1/responses) or just the method when
no route matched. A valid W3C traceparent header makes it a child of the
caller's span (and the caller's sampling decision is honoured); an absent or
invalid header starts a new trace. The span stays open until the response
body has been fully sent or the client disconnected, so streaming responses
are covered end to end. Attributes follow the HTTP semantic conventions and
are deliberately bounded:
| Attribute | Value |
|---|---|
http.request.method |
A well-known method, or _OTHER |
http.route |
The matched route template; absent for unmatched paths |
http.response.status_code |
Integer status |
url.scheme |
Always http: the gateway listens on plain HTTP and TLS, if any, terminates in front of it. Never copied from the request target |
network.protocol.version |
1.1, 2, ... |
error.type |
Only on outcomes the middleware itself observes: handler_panic, response_body, or cancelled |
Query strings, request or response headers, client addresses, request and
response bodies, and upstream error bodies are never recorded. Spans are
marked with error status on a 5xx response, when a handler panics, or when
the response body fails mid-stream. A request dropped before any response
existed — a timeout around the handler, the client disconnecting, or the
runtime shutting down mid-request — carries error.type=cancelled with no
status code and is not marked as an error. A 2xx on a streaming
response does not by itself mean the execution succeeded — that is what
the execution span below records.
WebSocket upgrades on /v1/responses produce a span for the upgrade request
only; the session and individual executions have separate traces described below.
Every HTTP request the executor runs — Responses or Messages, streaming or not —
produces one agentic.execute span as a child of the HTTP server span. It
opens when the executor accepts the request and closes when the response
payload has been handed back or, for a stream, when the last frame has been
yielded or the client dropped the stream. Work done while streaming (later
inference rounds, persistence after the last round) is attributed to it.
| Attribute | Value |
|---|---|
agentic.api |
responses | messages |
agentic.route |
executor | proxy |
agentic.stream |
Whether the client asked for a stream |
agentic.queue.wait |
WebSocket only: seconds between admission and dispatch, or discard while queued |
agentic.execution.outcome |
completed | incomplete | failed | cancelled |
agentic.delivery.outcome |
delivered | disconnected | not_started |
error.type |
On failed only, a bounded category derived from the error's kind, never its message: storage, persistence, conversation_locked, upstream_status, upstream_transport, upstream_error, network, parse, stream, not_found, invalid_request, payload_too_large, resource_limit, round_budget, conflict, compaction, tool, or panic |
Execution and delivery are recorded separately because they answer different questions:
execution.outcomeis what the executor concluded.completedandincomplete(a Responsesincompletestatus or a Messagesmax_tokensstop) are terminal results;failedmarks the span with error status; andcancelledmeans the executor was stopped before a terminal state — the stream was dropped or the task was aborted at shutdown — and is not an error.delivery.outcomeis whether the transport was handed everything the execution produced: the payload, or the terminal streamed frame (delivered); the stream was dropped before that frame (disconnected); or the execution failed before there was anything to send (not_started).
So an SSE error frame under an HTTP 200 is execution=failed,
delivery=delivered; a client that leaves mid-stream is
execution=cancelled, delivery=disconnected; and a Messages upstream error
body, which the gateway relays verbatim, is execution=failed,
error.type=upstream_error even though the handler never saw an Err.
No request, response, or conversation identifier, model name, prompt, tool argument, or error message appears on the span.
Stages are children of agentic.execute, including work the response stream
polls after the handler returns. A typical Responses tool loop has two
agentic.inference_round children with agentic.tool.execute between them;
each round contains its upstream http.client.request.
| Span | Allowed stage attributes |
|---|---|
agentic.rehydrate |
agentic.rehydrate.source: none, previous_response, or conversation |
agentic.inference_round |
agentic.inference.round: zero-based round index |
agentic.tool.execute |
agentic.tool.type: the gateway's tool category (function, tool_search, custom, shell, codex_namespace, mcp, web_search, file_search, or code_interpreter), never the tool name |
agentic.compaction |
agentic.compaction.operation: summarize; agentic.compaction.trigger: context_management, input_item, or explicit |
agentic.persist |
agentic.persist.destination: response or conversation |
Rehydration, persistence, compaction, and gateway tool stages describe the
Responses executor. Inference rounds and HTTP client spans also cover the
Messages executor. Explicit /v1/responses/compact requests emit compaction,
rehydration, and persistence stages under their HTTP span, without an
agentic.execute wrapper. Compaction's summarization HTTP call is a child
of its compaction span.
The http.client.request span has client kind and only
http.request.method=POST, server.address (host only), server.port,
http.response.status_code, and, on failure, error.type (timeout,
transport, or upstream_status). It measures sending the request and
receiving headers, including reading an HTTP error body; streamed body
consumption continues inside the inference round. It never records
url.full, a URL path/query, headers, or an error message.
Both upstream API adapters replace outgoing traceparent and tracestate
with the active HTTP client span's W3C context. These headers carry context,
not span attributes; inherited trace state may travel with that context.
Each socket has an independent root agentic.websocket.session span. Each
accepted response.create starts a new trace whose agentic.execute root
is linked to that session, not parented on it or on the HTTP upgrade.
This includes local generate:false requests. Invalid or rejected frames
do not create execution spans.
Pipelined requests start their spans on admission. agentic.queue.wait
records queue time in seconds at dispatch, or when an undispatched request
is discarded. Disconnecting cancels active and queued executions that have
not reached a terminal outcome. The session span has no application
attributes; links contain only trace context.
Raw Responses and Messages proxy routes produce a thin agentic.execute
with agentic.route=proxy, parented on the HTTP server span. They replace
the caller's outbound trace headers with that execution span's context
without changing the request or response bytes.
Proxy outcomes describe transport, not model execution: a non-success
HTTP status is failed with error.type=upstream_status; successfully
forwarding the body is completed/delivered. Streaming completion is
detected at EOF or when the declared content length is exhausted. An early
drop is cancelled/disconnected; a body read failure is
failed/disconnected with error.type=network. The proxy does not parse
SSE terminal events, so an upstream semantic error inside an HTTP 200 body
does not turn the proxy span into a failed execution.
Export tests enforce per-span attribute allow-lists and bounded enum values
across real executor, WebSocket, and proxy traces. They scan attribute values
against fixture prompts, tool arguments, credentials (Authorization and
x-api-key), URL queries, summaries, and upstream error bodies. No such
content, tool name, model name, or application identifier belongs in span
attributes. Log events are not exported as span events, and error status
descriptions are empty. Operator-configured resource attributes and W3C
trace context are separate from this application-attribute contract.
| Instrument | Type | Attributes |
|---|---|---|
http.server.request.duration (seconds) |
histogram | http.request.method, http.route, http.response.status_code, url.scheme |
http.server.active_requests |
up-down counter | http.request.method, url.scheme |
Duration boundaries are the semantic-convention set extended with 30, 60,
120, and 300 seconds because streamed inference responses routinely run
for minutes. Both instruments are recorded exactly once per request from a
guard attached to the response body, so a disconnecting client cannot leave
the active-request count drifting. Metrics are recorded regardless of the
trace sampling decision.
The default sampler is parentbased_always_on: HTTP traces honour a caller's
sampling decision carried in traceparent; requests without a parent are
sampled. WebSocket session and execution traces are independent roots, so
their sampling decisions come from the root sampler, not their links. For
high request volumes use ratio sampling:
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1Sampling affects only which spans are exported. Metrics and local logs are unaffected.
Local log output is unchanged and still filtered by RUST_LOG. When traces
are exported, every log line emitted inside an exported span ends with
trace_id=<32 hex> span_id=<16 hex>, so a line can be matched to the trace in
your backend:
2026-09-18T10:12:41.301Z INFO http.server.request{...}: agentic_server::handler: routing HTTP responses request route=executor trace_id=4bf92f3577b34da6a3ce929d0e0e4736 span_id=00f067aa0ba902b7
Two independent filters are in play:
RUST_LOG(defaultagentic_server=info,agentic_core=info,opentelemetry_sdk=warn,opentelemetry-otlp=warn) decides what is printed locally. It never changes what is exported.- The export bridge only forwards spans at
INFOand above whose target is one of this repository's crates (agentic_server,agentic_core,agentic_praxis,agentic_llm_d). Spans declared by dependencies are never exported, it never forwards log events, and exporting cannot feed back into itself.
The default RUST_LOG includes the SDK and exporter at WARN on purpose:
queue overflow and export failures are reported there, so a Collector that is
down is visible in the gateway's own logs.
Correlated lines are written without ANSI colour.
- Spans are batched by the SDK's batch processor on a dedicated thread. When
the queue (
OTEL_BSP_MAX_QUEUE_SIZE) is full, new spans are dropped: a warning is logged when dropping starts and the dropped count is reported at shutdown. Requests are never delayed or failed by export. - Metrics are collected by a periodic reader on its own thread.
- A slow or unreachable Collector only affects the exporter threads: each
export request is bounded by
OTEL_EXPORTER_OTLP_TIMEOUT. - On shutdown the gateway first drains in-flight requests (8 s budget), then stops the runtime with a 1 s deadline — any request still streaming past the drain budget is dropped here, which records its final span and metrics — and only then flushes and shuts down the telemetry providers with a 3 s deadline. A hung Collector therefore cannot hold the process beyond roughly 12 s, well inside the 30 s termination grace period used by the Kubernetes manifests. Spans still buffered when the deadline passes are lost.
Add the variables to the gateway ConfigMap (see
deploy/kubernetes/configmap.yaml),
pointing at a Collector Service or DaemonSet in the cluster:
data:
OTEL_TRACES_EXPORTER: "otlp"
OTEL_METRICS_EXPORTER: "otlp"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability.svc:4318"
OTEL_SERVICE_NAME: "agentic-api"
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=staging"The shipped NetworkPolicy restricts ingress only, so no egress rule is
needed for the Collector.