Per-tenant OTel routing: how it works
Every request passing through the gateway emits an OTLP trace. The pattern is borrowed from Bifrost’sObservabilityPlugin.Inject(ctx, trace) primitive, adapted for LangWatch’s attribution model:
- At auth resolution the gateway knows
vk_id → project_id → team_id → org_id → principal_id. - Every span in the request’s trace is tagged with these as
langwatch.*attributes. - The bundle returned by
/api/internal/gateway/config/:vk_idcarriesproject_id, the gateway uses it to tag every span on the trace. - The gateway ships all traces to a single endpoint (
OTEL_DEFAULT_EXPORT_ENDPOINT, defaulthttps://app.langwatch.ai/otel/v1/traces). - LangWatch’s ingest pipeline reads
langwatch.project_idoff each span and stores the trace under the owning project, the attribution happens at ingest, not at export.
Self-hosted
On self-hosted deployments, setOTEL_DEFAULT_EXPORT_ENDPOINT to your in-cluster OTel collector (or LangWatch ingestion endpoint for hybrid setups). Attribution works the same way: per-project span attributes are filed at ingest.
Attributes on every gateway span
Source of truth:services/aigateway/adapters/gatewaytracer/attrs.go. The gateway emits these attributes on every span (via the *chi.Context helper and the dispatch handlers):
Per-feature attributes (when applicable)
langwatch.cache.rule_id,langwatch.cache.rule_priority,langwatch.cache.mode_applied, emitted when a bundle-baked cache-control rule matches and determines the final effective cache mode (post header-vs-rule-vs-default precedence). See Cache control.langwatch.guardrail.verdict, aggregate verdict from pre/post guardrail evaluation.langwatch.fallback.attempts_count, total attempts before success (1 = no fallback).langwatch.fallback.winning_provider, provider that ultimately served the request.langwatch.fallback.winning_credential, credential ID of the winning provider slot.langwatch.thread_id, thread/conversation ID when provided viaX-LangWatch-Thread-Idheader.
The following attributes are not yet emitted (tracked for v1.1):
langwatch.policy.blocked, langwatch.budget.breached_scope, .warnings, langwatch.stream.*, langwatch.client.name, langwatch.cache.outcome, .forced_injected. Operators looking for these signals should use the Prometheus counters documented below. Request-id correlation via the X-LangWatch-Request-Id header lets operators join metric spikes back to individual traces.Filtering in the LangWatch UI
Attribute-based filters in the Messages view compose into dashboards:- “All gateway traffic this week”:
attr.langwatch.endpoint != "". - “Claude Code usage by engineer”:
attr.langwatch.client.name = "claude-code", group byprincipal_id. - “Cache-economics dashboard”: sum
gen_ai.usage.cache_read.input_tokens,gen_ai.usage.input_tokensover 7 days. - “Fallback incidents”:
attr.langwatch.fallback.attempt > 0, group byfallback.reason. - “Blocked by policy”:
attr.langwatch.policy.blocked != "".
Metrics (Prometheus)
The gateway servesGET /metrics on its main HTTP port (5563 by default, the same listener that serves /v1/* and the health probes). No separate admin port.
Traffic and lifecycle
Provider dispatch and fallback
outcome is one of success, fallback_success, retryable_5xx, not_found, rate_limit, timeout, network, circuit_open, non_retryable, chain_exhausted, context_done.
Auth cache
tier is l1 (in-process) or l2_redis. Only l1 is populated in the default deployment, which ships without an L2 Redis tier.
Governance
Streaming
Control plane
endpoint is one of resolve-key, guardrail-check, config, codex-refresh. The change-feed long poll is deliberately excluded: it blocks until an event or its own timeout, so timing it would say nothing about control-plane health.
The registry also carries the standard Go runtime and process collectors (go_*, process_*).
Scrape with a ServiceMonitor (Prometheus Operator) or standard scrape config, see Self-Hosting → Helm.
When OTel and metrics disagree
OTel traces are sampled (configurable at the collector level), metrics are exact counters. If your metrics show 1k requests but the LangWatch UI only has 100 traces for a given window, check the OTel sampling rate on your collector. The gateway itself exports all spans, sampling is applied downstream at the collector or ingest layer.Debugging a single request
From a log line or an error at the client, grab theX-LangWatch-Request-Id (grq_01HZX9K3M…). Paste into the LangWatch search bar and you land on the full trace: every attempt span, upstream latency, guardrail decisions, cache outcome, budget deltas. No digging through provider-side logs.
Trace-id propagation: concrete handshake
Every gateway response carries the following headers for W3C traceparent propagation and per-tenant OTel routing:Client already has a trace
Settraceparent on the outbound request (OpenAI/Anthropic SDKs do this automatically when OTel instrumentation is active, or pass default_headers={"traceparent": ...}). The gateway:
- Extracts the trace id from
traceparent. - Creates its gateway span as a child of that trace id with a fresh span id.
- Returns
X-LangWatch-Trace-Idequal to the caller’s trace id (no new trace is created, no double cost attribution). - Re-injects
traceparenton the response with the gateway’s span id so you can chain further hops.
Client has no trace
Notraceparent sent. The gateway mints a new trace and returns its id via X-LangWatch-Trace-Id. The response traceparent carries that id; propagate it to downstream services to stitch everything into one trace.
Verifying the handshake end-to-end
x-langwatch-trace-id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (same 32-hex as input) and a new 16-hex x-langwatch-span-id. The traceparent on the response will chain under that same trace id.
Without the incoming traceparent you’ll get a fresh 32-hex trace id instead.
See SDKs → trace propagation for language-specific recipes.