Expand description
Observability substrate (DW-021, feature analysis sections 4.17 and 4.19): tracing spans per request phase, structured JSON access logs, Prometheus metrics, request IDs, and the unified error envelope.
§Tracing and spans
tracing (MIT) is the substrate. Every request opens a root span
named request carrying request_id, method, path (WITHOUT the
query string — redaction: query strings carry tokens), consumer,
route, and listener fields, plus child spans at each phase:
authn, authz, ratelimit, admission, upstream_pick (emitted
inside the upstream handle where the pick actually happens), and one
upstream_attempt span per send. The binary installs a
tracing-subscriber JSON formatter over STDOUT filtered by
DWARA_LOG (RUST_LOG syntax, default dwara=info).
§OTLP decision
The OTLP exporter (opentelemetry + opentelemetry-otlp) was
evaluated and NOT added in v1: the crates are heavy (their own HTTP
transport, tonic/protonic codegen) against a musl <25MB binary-size
budget (DW-026) and a compute-conscious CI. The span STRUCTURE ships
now and is proven by an in-process span-capture test (one trace shows
all phases). Since #126 the exporter exists behind the default-off
otlp cargo FEATURE on dwara-bin (the env var DWARA_OTLP_ENDPOINT
is a binary-level knob and the tracing subscriber the exporter must
hook lives in the bin; this module stays dependency-light by design).
Default build: DWARA_OTLP_ENDPOINT remains RESERVED but inert;
feature build: the spans defined here export over http/protobuf.
§Access logs
One event at target dwara::access per completed request (rendered
as one JSON line by the binary’s subscriber): ts (subscriber
timestamp), request_id, method, path (sanitized: no query),
status, duration_ms, route, consumer, upstream,
endpoint, attempts, and the rate_limited / broken / shed
flags. bytes_in/bytes_out are deliberately omitted: the proxy
path is zero-buffering by design (DW-009) and body sizes are not
cheaply available for streamed bodies — recorded here as an honest
omission rather than a wrong number. Sampling: DWARA_ACCESS_LOG_ SAMPLE (0.0-1.0, default 1.0); responses with status >= 500 are
ALWAYS logged regardless of sampling (documented rule).
§Metrics
Prometheus text format on /metrics, reserved on every HTTP(S)
listener exactly like /healthz (a configured route matching it is
shadowed — same accepted v1 behavior). The prometheus crate (MIT,
default features off: text format only, no protobuf exporter) backs
the registry. Metric families:
requests_total{route,listener,status_class}counterrequest_duration_seconds{route}histogramupstream_attempts_total{upstream,endpoint,status_class}counterretries_total{upstream}counterdwara_hedge_sent_total{upstream}counter (DW-063) — speculative hedge copies sent after the tail-latency timer fireddwara_mirror_sent_total{upstream}counter (DW-062) — shadow traffic mirror requests sent to a mirror upstreamrate_limited_total{route}counterdwara_rate_limiter_adaptive_factor{policy}gauge (DW-089) — the current adaptive rate-limit factor per policy (1.0 = configured rate; < 1.0 tightens, > 1.0 relaxes; bounded [min, max])dwara_rate_limiter_origin_signal_total{policy,signal}counter (DW-089) — upstream origin signals received (retry_after), by policy and signal (both config-bounded)dwara_rate_limiter_adaptive_tightened_total{policy}counter (DW-089) — adaptive factor decreases, by policydwara_rate_limiter_adaptive_relaxed_total{policy}counter (DW-089) — adaptive factor increases, by policydwara_canary_promotions_total{group}counter (DW-091) — canary promotions, by group (service name or AI alias)dwara_canary_rollbacks_total{group}counter (DW-091) — canary rollbacks (including severe-regression immediate rollbacks to 0)dwara_canary_weight{group}gauge (DW-091) — current canary weight, by group (the total weight stays constant)shed_total{priority}counterdwara_policy_dry_run_total{phase,route}counter (DW-041) — requests a dry-run (monitor) policy would have rejected, by phase (route_limits,authz,rate_limit,load_shed) and route (unroutedfor pre-404 listener/global policies); the request was ALLOWED. Together with thedwara::policywarn events (phase, would-be status, reason, route, consumer, request id) this family IS the dry-run report — no separate endpoint.breaker_state{upstream}gauge (0 closed, 1 open, 2 half-open)endpoint_health{upstream,endpoint}gauge (1 available, 0 ejected)upstream_fail_open_picks{upstream}gauge (scrape-time snapshot of the balancer’s monotonic fail-open counter; a gauge rather than a counter so the hot pick path stays free of registry coupling)active_requestsgaugeconfig_generationgaugejwks_refresh_total{provider}counterdwara_rate_limiter_evictions_totalgauge (scrape-time snapshot of the rate-limit engine’s monotonic eviction counter, aggregated over every compiled rule; resets when a reload rebuilds the engine; a gauge rather than a counter so the hot check path stays free of registry coupling)dwara_rate_limiter_live_keysgauge (live per-key cells across every compiled rule, approximate, bounded by the sharded store cap; aggregate and unlabeled — cardinality is never per key)dwara_webhook_events_total{kind,outcome}counter (DW-044): webhook deliveries by event kind and outcome —delivered(2xx on some attempt),failed(retries exhausted / non-transient answer / budget spent),dropped(never tried: envelope over the byte cap, or delivery concurrency saturated). Both labels are closed sets, so cardinality is bounded by construction.dwara_events_dropped_total/dwara_events_emitted_totalgauges (DW-044, scrape-time snapshots of the event bus’s monotonic counters — gauges rather than counters so the emit path stays a plain atomic with zero registry coupling, the same model as the rate-limiter eviction gauge): events dropped at EMIT time (full queue / no deliverer) and events handed to the queue.dwara_access_records_streamed_total{outcome}counter (DW-121): access records counted per flushed-batch delivery outcome —delivered/failed(tried and not taken) /dropped(per-record byte cap) — a closed three-value label set, counted by the stream flusher, never the request path.dwara_access_records_offered_total/dwara_access_records_dropped_totalgauges (DW-121, scrape-time snapshots of the record stream’s monotonic counters, the same gauge-not-counter model as the event bus): records offered at request completion and records dropped at OFFER time (channel full / stream disabled).dwara_quota_denied_total{consumer,budget}counter (DW-033) — requests refused by a consumer request budget (429), by consumer (the quota-configured set, config-bounded) and binding budget (daily/monthly, a closed set). Distinct fromrate_limited_total: budgets are not rates.dwara_quota_used{consumer,budget}/dwara_quota_limit{consumer, budget}gauges (DW-033, scrape-time snapshots of the state store’s quota counters and the config caps — the same snapshot gauge model as the rate-limiter live-keys gauge).dwara_admission_queued_total{outcome}counter (DW-053) — admission queue outcomes by closed setadmitted(a permit was acquired after queueing),timeout(the queue timeout expired; shed with 503),queue_full(the queue was at capacity; shed immediately with 503).dwara_admission_queue_depthgauge (DW-053) — current number of requests waiting in the admission queue for a concurrency permit.dwara_license_statusgauge (DW-032) — 0 = no license (OSS mode), 1 = valid, 2 = expired within grace period, 3 = expired past grace period (degraded to OSS). Set at startup and on reload; no labels.dwara_dns_discovery_endpoints{upstream}gauge (DW-042) — current resolved endpoint count for DNS-discovered upstreams. Theupstreamlabel is config-bounded; cardinality is never per resolved address.dwara_dns_discovery_refresh_total{upstream}counter (DW-042) — DNS discovery refresh attempts, by upstream (config-bounded label).dwara_dns_discovery_refresh_failures_total{upstream}counter (DW-042) — DNS discovery refresh failures, by upstream.dwara_mcp_sessions_total{state}counter (DW-087) — MCP session lifecycle transitions, by state (initialized,closed,expired). A closed three-value label set, so cardinality is three series total.dwara_mcp_tool_calls_total{tool,status}counter (DW-087) — MCP tool calls, by tool (config-bounded) and status (success,error,denied). No consumer label (cardinality rule).dwara_mcp_tool_duration_seconds{tool}histogram (DW-087) — MCP tool call duration (authz check through upstream response), by tool (config-bounded label).dwara_a2a_requests_total{agent,outcome}counter (DW-114) — A2A (agent-to-agent) requests, by agent (config-bounded) and outcome (success,error,stubbed). No consumer label (cardinality rule).dwara_a2a_sessions_total{state}counter (DW-114) — A2A session lifecycle transitions, by state (initialized,closed,expired). A closed three-value label set (mirrors MCP), so cardinality is three series total.dwara_tls_pq_handshakes_total{result}counter (DW-105) — post-quantum TLS handshake outcomes, by result (success,fallback,disabled). A closed three-value label set, so cardinality is three series total.dwara_graphql_requests_total{route,outcome}counter (DW-099) — GraphQL check outcomes, by route (config-bounded) and outcome (allowed,depth_exceeded,complexity_exceeded,persisted_query_required— a closed four-value set).dwara_graphql_depth{route}gauge (DW-099) — last query depth for a GraphQL-configured route (config-bounded label).dwara_nano_service_requests_total{route,outcome}counter (DW-106) — nano-service (WASM route handler) request outcomes, by route (config-bounded) and outcome (success,error,timeout— a closed three-value set). No consumer label (cardinality rule).dwara_nano_service_duration_seconds{route}histogram (DW-106) — nano-servicehandlecall duration, by route (config-bounded label).dwara_plugin_failures_total{name,reason}counter (DW-157) — plugin dispatch failures, by plugin name (config-bounded) and reason (crashed,disabled,not_loaded,not_registered,instantiate_failed,trap,body_too_large,response_stream_ended— a closed set). One increment per request the plugin chain answered with an error (a plugin short-circuit viasend_http_responseis a plugin DECISION, not a failure, and is not counted; the access-logplugin_short_circuitflag marks those).dwara_plugin_total{state}gauge (DW-158) — plugins loaded per lifecycle state (healthy,crashed,disabled,not_loaded,not_registered— a closed five-value set over the generation’s declared plugins, the same enumerationGET /pluginsserves). Refreshed at publish time (every config generation); state changes that happen between publishes (a crash counted at request time) land on the next publish or are visible per-request indwara_plugin_failures_total.dwara_graphql_complexity{route}gauge (DW-099) — last query complexity for a GraphQL-configured route (config-bounded label).dwara_l4_connections_total{listener,protocol}counter (DW-103) — L4 connections proxied, by listener (config-bounded) and protocol (tcp,udp— a closed two-value set). Counted once per accepted L4 connection (before the splice is established).dwara_l4_connection_duration_seconds{listener}histogram (DW-103) — L4 connection duration (splice establishment through close), by listener (config-bounded label).dwara_ai_request_duration_seconds{provider,route}histogram (AI-07) — AI request latency (request parse through response translation), by provider and route (both config-bounded). Observed once per completed AI request (success and provider/translation errors alike; client-side rejections never reach a provider).dwara_ai_tokens_per_request{provider,model,kind}histogram (AI-07) — distribution of provider-reported tokens per request, by provider (config-bounded), provider_model (config-bounded), and kind (the closed setprompt|completion). Provider-reported only; the gateway never estimates. No consumer label (cardinality).
§OTel GenAI semantic conventions (AI-07)
AI request spans carry the OpenTelemetry GenAI semantic-convention
gen_ai.* attributes (https://opentelemetry.io/docs/specs/semconv/gen-ai/):
gen_ai.system (the provider kind), gen_ai.request.model
(the client alias), gen_ai.response.model (the provider model that
served), gen_ai.request.{max_tokens,temperature,top_p}, and the
provider-reported gen_ai.usage.{prompt_tokens,completion_tokens, total_tokens}, gen_ai.response.finish_reasons, and
gen_ai.response.id. The helpers record_gen_ai_request_attrs and
record_gen_ai_response_attrs record them onto a span created with
every gen_ai.* field declared Empty (the span lives in
dataplane::ai_proxy::serve_ai).
§Error envelope (section 4.19)
Every gateway-generated non-success body — including the reserved
/healthz//readyz responses, which are aligned to the same shape —
is {"error":{"code","message","request_id"}}. Messages never leak
upstream internals: classification strings only (“upstream
unavailable”, not hyper error text).
§Request IDs
An inbound X-Request-Id is respected when it is printable ASCII of
at most 128 bytes (anything else is replaced — a hostile ID must not
smuggle control bytes into logs); otherwise a process-unique ID is
generated (wall-clock nanoseconds + per-process counter — cheaper
than a UUID, no extra dependency). The ID is echoed on the response
as X-Request-Id and available to every span and log line.
§Redaction rules (hard requirements)
- Paths are logged WITHOUT query strings.
- No Authorization / Proxy-Authorization / Cookie / Set-Cookie / X-API-Key VALUES are ever emitted by this module or the tracing calls it fronts; field lists below are exhaustive.
- JWKS bodies, credentials, and keys are never logged.
Structs§
- Access
Record - The accumulated access-log record for one request. The proxy fills
fields as the request progresses;
finishstamps status/duration. - Listener
Label - Extension inserted by the listener frontend (dwara-bin) naming the
listener that accepted the request; absent when
proxy::handleis driven directly (tests), in which case the label is “unknown”. - Observability
- Per-dataplane observability state: the metric families plus the
access-log sampling knob. One instance per
DataPlane, so parallel tests never share a registry (duplicate-metric registration errors). - SloTargets
- Plain per-route SLO targets. Primitives on purpose: observability depends on nothing, so the config types are converted at the call site (dataplane refresh).
Constants§
- X_
CORRELATION_ ID - Request header carrying the business correlation ID (DW-093):
inbound respected (falls back to
X-Request-Id), outbound echoed on the response so a client can tie a reply to its journey across multiple gateway requests. - X_
REQUEST_ ID - Request header carrying the correlation ID (inbound respected, outbound always set by the gateway).
Functions§
- emit_
access - Emit one access-log event (one JSON line under the binary’s
subscriber). The field list is exhaustive and redacted by
construction: no headers, no query string, no credentials.
bytes_in/bytes_out(PERF-12, #209):bytes_inis the declaredContent-Length(0 for chunked);bytes_outis the frame-counted response body size (read from the atomic theCountingBodywrapper updated as frames streamed to the client). - envelope_
body - The unified error body (section 4.19): a JSON object an operator can
grep and a client can correlate.
codeis a stable machine token,messagea human string with no upstream internals,request_idties the response to the trace/access log. - generate_
request_ id - Public for testing the request-id generation contract.
- record_
gen_ ai_ request_ attrs - Record the request-side OTel GenAI semantic-convention attributes
(AI-07) onto an AI span. The span MUST have been created with every
gen_ai.*field declared astracing::field::Empty(seegen_ai_span_fields);Span::recordonly fills fields the span’s metadata already declared. Request-side fields are always available once the canonicalcrate::ai::types::ChatRequestis parsed; thesystemis the serving provider’s kind (crate::config::ai::AiProviderKind::gen_ai_system). - record_
gen_ ai_ response_ attrs - Record the response-side OTel GenAI semantic-convention attributes
(AI-07) onto an AI span. Each argument is recorded only when present
(provider-reported only; the gateway never estimates).
finish_reasonsis the per-choice finish-reason token list (one entry per choice). - resolve_
correlation_ id - Resolve the request’s business correlation ID (DW-093): a valid
inbound
X-Correlation-Idverbatim, else the already-resolvedrequest_id(so every request carries a non-empty correlation handle for the journey/funnel query even when the client did not send one). The validity gate mirrorsvalid_inbound_request_id(printable ASCII, at most 128 bytes) so hostile IDs cannot smuggle control characters into the analytics store. - resolve_
request_ id - Resolve the request’s correlation ID: a valid inbound
X-Request-Idverbatim, else a generated one (req-+ hex wall-clock nanoseconds + hex process counter; unique per process, no dependency pulled in for randomness — request IDs are correlation handles, not secrets). - stamp_
correlation_ id - Echo the correlation ID onto a response (insert, never append:
exactly one
X-Correlation-Id, the gateway’s resolved one). - stamp_
request_ id - Echo a request ID onto a response (insert, never append: exactly one
X-Request-Id, the gateway’s resolved one). - status_
class - Prometheus status-class label (“2xx”, “5xx”, …).
- valid_
inbound_ request_ id - Validate an inbound
X-Request-Idvalue: printable ASCII (0x20-0x7E), at most 128 bytes. Anything else (control bytes, UTF-8 multibyte, overlong) is rejected so hostile IDs cannot smuggle control characters into logs.