shunt can export traces, metrics, and logs over OTLP/HTTP to your own OpenTelemetry Collector (or any OTLP-compatible backend). It is opt-in and off by default — with no [otel] section, nothing leaves the machine — and runs independently of Sentry, so you can enable either or both.
This page is about shunt’s own telemetry, going out. The gateway’s separate telemetry ingest handles the opposite direction: OTLP payloads that managed Claude Code clients send to shunt, relayed verbatim to the destinations in [server.gateway.telemetry]. The two are configured independently and may target different collectors.
Enable it
One key turns it on — point it at your Collector’s OTLP/HTTP receiver:
[otel]
endpoint = "http://localhost:4318" # OTLP/HTTP base URL; shunt appends /v1/{traces,metrics,logs}Everything else has a sensible default:
[otel]
endpoint = "http://localhost:4318"
service_name = "shunt" # (default) service.name resource attribute
environment = "prod" # optional deployment.environment.name
sample_ratio = 1.0 # (default) head-based trace sampling, 0.0–1.0
traces = true # (default) export request spans
metrics = true # (default) export usage metrics
logs = true # (default) export log events (stderr logs unaffected)
include_session_id = false # (default) keep the client session id off spans
[otel.headers] # optional per-request headers, e.g. a hosted-collector token
authorization = "Bearer <token>"Setting endpoint = "" (e.g. SHUNT_OTEL__ENDPOINT="") disables export again without deleting the section. An invalid endpoint, a non-http(s) URL, or an out-of-range sample_ratio is a startup error, so a typo can’t silently drop every export.
The three signals
| Signal | What’s exported | Notes |
|---|---|---|
| Traces | The per-request proxy_request span (and codex_endpoint_request on the inbound Codex endpoint), tagged with gen_ai.request.model, shunt.provider, shunt.client_kind, shunt.managed, http.response.status_code, and otel.status_code (error on a 5xx response, or on a streaming request that fails mid-stream after a 200 header — an event: error frame or a connection cut before a terminal event, issue #287) |
Head-based sampling via sample_ratio. Low-cardinality; no request/response bodies, headers, or credentials. |
| Metrics | The low-cardinality series listed below | The same series shunt sends to Sentry when [sentry] metrics = true. |
| Logs | shunt’s tracing log events, bridged to OTLP |
The stderr logs are unaffected. |
Each signal toggles independently via traces / metrics / logs.
Metric series
| Series | Type | Attributes | Meaning |
|---|---|---|---|
shunt.requests |
Counter | provider, model, http.response.status_code, caller, client_kind, managed |
Proxied inference requests. caller is client for inbound traffic and router for a judge call a [models.router] entry made on the caller’s behalf. |
shunt.latency |
Histogram (ms) | provider, model, http.response.status_code, caller, client_kind, managed |
Header latency for streams; full latency otherwise. The committed streaming responses sample from dispatch to attempt classification, since their headers commit before the upstream responds. |
shunt.ttft |
Histogram (ms) | provider, model, client_kind, managed |
Request start to the first complete non-keepalive SSE frame. |
shunt.stream_outcome |
Counter | provider, model, outcome, client_kind, managed |
One final SSE result: completed, error_event, upstream_cut, or client_disconnect. |
shunt.tokens |
Counter | provider, model, kind, client_kind, managed |
Last reported streaming token usage (input, output, cache_read, cache_creation). Non-streaming usage is not recorded. |
shunt.codex_continuation |
Counter | provider, outcome |
Codex WebSocket continuation hit or fallback. |
shunt.codex_ws_overflow |
Counter | provider, outcome |
Codex WebSocket dedicated overflow connection opened or ceiling-refused for a concurrent turn (issue #248). |
shunt.codex_client_events |
Counter | event |
Codex CLI analytics events by sanitized event name; payloads and properties are discarded. |
shunt.gateway_telemetry_ingest |
Counter | signal, outcome |
Inbound gateway OTLP payloads (metrics/logs/traces) by ingest outcome: relayed, discarded when no destination opted in, shed when the in-flight relay limit was saturated, or rejected for a bad bearer or over-cap body. |
shunt.upstream_retries |
Counter | provider, reason |
Bounded transient upstream retries. |
shunt.failover |
Counter | provider, state |
Ordered-upstream failover transitions: attempted, advanced, or exhausted. |
shunt.router.decisions |
Counter | model, algorithm, target, source |
Routing decisions for every [models.router] type, and for the [models.subagents] overlay (algorithm = "subagents"), by the deciding [[models]] entry’s id (as matched, so with any [1m] hint already stripped), the configured type, the model id the decision resolved to, and why it was chosen. count_tokens probes are excluded. |
shunt.stage_router.decisions |
Counter | model, tier, source |
Stage-router tier decisions, by the router’s model id (as matched, so with any [1m] hint already stripped), the chosen tier (capable/efficient), and why it was chosen. count_tokens probes are excluded. |
shunt.router.judge_calls |
Counter | model, algorithm, outcome |
Judge calls made by a [models.router] entry that configures one, by the router’s model id, the configured type, and how the call resolved: decided, or one of timeout, oversized, upstream_error, invalid_reply, translation_failed, budget_exhausted (the call was refused because the session spent max_judge_calls). A reply the upstream cut short counts as upstream_error, not invalid_reply. On a stage router each of these routes the turn to the picker default; on an llm_classifier or composite entry, or a classifier-form [models.subagents] overlay, the algorithm’s own fallback closes it, and a turn answered from a retained assignment with no call records retained. On an escalation or advisor entry a failure after the completed turn serves that held turn instead — an advisor with fail_open = false fails the request, and the failed review still counts under its outcome (e.g. upstream_error or timeout), or as budget_exhausted when max_judge_calls refused it — and an advisor review is counted under algorithm = "advisor"; the held turn itself is not a judge call. |
shunt.stage_router.flips |
Counter | model, from, to |
Decisions that moved a session off its pinned tier. The two directions stay separate because escalation is deliberately easier than de-escalation. |
shunt.requests_shed |
Counter | — | Inbound requests rejected at the max_concurrent_requests limit. The rejection itself is logged only at debug!, so this counter is what makes a saturated gateway visible. |
shunt.pool.quota_utilization |
Gauge | provider, window |
Minimum utilization across enabled, observed, non-expired quota values for 5h, 7d, or 7d_oi. |
shunt.pool.rotations |
Counter | provider, reason |
Moves off an account and requests that exhaust the pool. |
shunt.pool.reprobes |
Counter | provider |
Reprobes committed at first HTTP dispatch for stale near-quota Codex/ChatGPT accounts; WebSocket-enabled providers count inbound HTTP probes only. |
shunt.store.write_failures |
Counter | subsystem |
Failed writes to the optional [store], by subsystem: pool (write-behind flush), spend, or gateway (token endpoint). A failed pool flush is retried on the next 15 second tick unless this process lost the store hold, which stops pool flushing. A failed spend write is reported to the admin caller, and a failed token-endpoint write is reported to the client; a failed background gateway flush has no caller and is only logged. |
shunt.upstream.status |
Gauge | provider |
Observed [server.status] Statuspage severity: 0 none, 1 minor, 2 major, 3 critical. A provider currently unknown (no signal) is omitted from this series rather than reported as 0. |
The five per-request series carry two extra attributes. client_kind is claude-code, codex, or other, read from the request’s own client headers (x-app, originator, user-agent) — the raw header value is never exported. managed is true only when the request authenticated with a gateway-issued JWT, the credential that also makes a client eligible for the pushed OTEL_* environment. Together they separate shunt’s server-derived series from a gateway-mode client’s own claude_code.* export, so a dashboard can take claude_code.* where it exists and shunt.*{managed="false"} everywhere else without double counting. That filter assumes the backend receives the managed clients’ claude_code.* metrics, relayed through a [server.gateway.telemetry].forward_to destination with metrics on or exported by the clients directly; when it does not, read shunt.* across both managed values instead. A judge call (caller="router") keeps the originating turn’s client_kind but is always managed="false", because it is shunt’s own traffic that no client exporter reports; requests to the inbound Codex endpoint are always managed="false" too, since it accepts only [server.auth] credentials. The same two values are recorded on the request span as shunt.client_kind and shunt.managed. An inbound Codex WebSocket turn has no request span, so its attribution appears on the metrics only.
Privacy
shunt never exports request/response bodies, headers, or credentials in metrics and traces.
- Metrics and traces stay low-cardinality and body-free. With OTLP trace export, the request span’s client session id is sent to the collector only when
include_session_id = true(default off), and only while trace export is active. The same rule governs Sentry trace export ([sentry] traces_sample_rate/include_session_id); with no span export active at all, the id stays on the local request span as before. - Logs mirror shunt’s own diagnostic events as written, so — like the stderr logs — they can include request-derived fields (an upstream error body, an authenticated client id). For strictly body-free export, set
logs = falseand keep metrics/traces.
The exported resource advertises service.*, telemetry.sdk.*, and — when environment is set — deployment.environment.name; no host or process detector runs, so the machine’s hostname is not attached, plus whatever you put in the standard OTEL_RESOURCE_ATTRIBUTES.
Standard OTEL_ env vars
endpointandservice_namecome from this config and take precedence overOTEL_EXPORTER_OTLP_ENDPOINT/OTEL_SERVICE_NAME.- The standard
OTEL_EXPORTER_OTLP_HEADERSandOTEL_RESOURCE_ATTRIBUTESare still merged in, on top of[otel.headers]and the built-in resource attributes.
See the [otel] configuration reference for every key.