dwara_core/
observability.rs

1//! Observability substrate (DW-021, feature analysis sections 4.17 and
2//! 4.19): tracing spans per request phase, structured JSON access logs,
3//! Prometheus metrics, request IDs, and the unified error envelope.
4//!
5//! ## Tracing and spans
6//!
7//! `tracing` (MIT) is the substrate. Every request opens a root span
8//! named `request` carrying `request_id`, `method`, `path` (WITHOUT the
9//! query string — redaction: query strings carry tokens), `consumer`,
10//! `route`, and `listener` fields, plus child spans at each phase:
11//! `authn`, `authz`, `ratelimit`, `admission`, `upstream_pick` (emitted
12//! inside the upstream handle where the pick actually happens), and one
13//! `upstream_attempt` span per send. The binary installs a
14//! `tracing-subscriber` JSON formatter over STDOUT filtered by
15//! `DWARA_LOG` (RUST_LOG syntax, default `dwara=info`).
16//!
17//! ## OTLP decision
18//!
19//! The OTLP exporter (`opentelemetry` + `opentelemetry-otlp`) was
20//! evaluated and NOT added in v1: the crates are heavy (their own HTTP
21//! transport, tonic/protonic codegen) against a musl <25MB binary-size
22//! budget (DW-026) and a compute-conscious CI. The span STRUCTURE ships
23//! now and is proven by an in-process span-capture test (one trace shows
24//! all phases). Since #126 the exporter exists behind the default-off
25//! `otlp` cargo FEATURE on dwara-bin (the env var `DWARA_OTLP_ENDPOINT`
26//! is a binary-level knob and the tracing subscriber the exporter must
27//! hook lives in the bin; this module stays dependency-light by design).
28//! Default build: `DWARA_OTLP_ENDPOINT` remains RESERVED but inert;
29//! feature build: the spans defined here export over http/protobuf.
30//!
31//! ## Access logs
32//!
33//! One event at target `dwara::access` per completed request (rendered
34//! as one JSON line by the binary's subscriber): `ts` (subscriber
35//! timestamp), `request_id`, `method`, `path` (sanitized: no query),
36//! `status`, `duration_ms`, `route`, `consumer`, `upstream`,
37//! `endpoint`, `attempts`, and the `rate_limited` / `broken` / `shed`
38//! flags. `bytes_in`/`bytes_out` are deliberately omitted: the proxy
39//! path is zero-buffering by design (DW-009) and body sizes are not
40//! cheaply available for streamed bodies — recorded here as an honest
41//! omission rather than a wrong number. Sampling: `DWARA_ACCESS_LOG_
42//! SAMPLE` (0.0-1.0, default 1.0); responses with status >= 500 are
43//! ALWAYS logged regardless of sampling (documented rule).
44//!
45//! ## Metrics
46//!
47//! Prometheus text format on `/metrics`, reserved on every HTTP(S)
48//! listener exactly like `/healthz` (a configured route matching it is
49//! shadowed — same accepted v1 behavior). The `prometheus` crate (MIT,
50//! default features off: text format only, no protobuf exporter) backs
51//! the registry. Metric families:
52//!
53//! - `requests_total{route,listener,status_class}` counter
54//! - `request_duration_seconds{route}` histogram
55//! - `upstream_attempts_total{upstream,endpoint,status_class}` counter
56//! - `retries_total{upstream}` counter
57//! - `dwara_hedge_sent_total{upstream}` counter (DW-063) — speculative
58//!   hedge copies sent after the tail-latency timer fired
59//! - `dwara_mirror_sent_total{upstream}` counter (DW-062) — shadow
60//!   traffic mirror requests sent to a mirror upstream
61//! - `rate_limited_total{route}` counter
62//! - `dwara_rate_limiter_adaptive_factor{policy}` gauge (DW-089) — the
63//!   current adaptive rate-limit factor per policy (1.0 = configured
64//!   rate; < 1.0 tightens, > 1.0 relaxes; bounded [min, max])
65//! - `dwara_rate_limiter_origin_signal_total{policy,signal}` counter
66//!   (DW-089) — upstream origin signals received (`retry_after`), by
67//!   policy and signal (both config-bounded)
68//! - `dwara_rate_limiter_adaptive_tightened_total{policy}` counter
69//!   (DW-089) — adaptive factor decreases, by policy
70//! - `dwara_rate_limiter_adaptive_relaxed_total{policy}` counter
71//!   (DW-089) — adaptive factor increases, by policy
72//! - `dwara_canary_promotions_total{group}` counter (DW-091) — canary
73//!   promotions, by group (service name or AI alias)
74//! - `dwara_canary_rollbacks_total{group}` counter (DW-091) — canary
75//!   rollbacks (including severe-regression immediate rollbacks to 0)
76//! - `dwara_canary_weight{group}` gauge (DW-091) — current canary
77//!   weight, by group (the total weight stays constant)
78//! - `shed_total{priority}` counter
79//! - `dwara_policy_dry_run_total{phase,route}` counter (DW-041) —
80//!   requests a dry-run (monitor) policy would have rejected, by phase
81//!   (`route_limits`, `authz`, `rate_limit`, `load_shed`) and route
82//!   (`unrouted` for pre-404 listener/global policies); the request was
83//!   ALLOWED. Together with the `dwara::policy` warn events (phase,
84//!   would-be status, reason, route, consumer, request id) this family
85//!   IS the dry-run report — no separate endpoint.
86//! - `breaker_state{upstream}` gauge (0 closed, 1 open, 2 half-open)
87//! - `endpoint_health{upstream,endpoint}` gauge (1 available, 0 ejected)
88//! - `upstream_fail_open_picks{upstream}` gauge (scrape-time snapshot of
89//!   the balancer's monotonic fail-open counter; a gauge rather than a
90//!   counter so the hot pick path stays free of registry coupling)
91//! - `active_requests` gauge
92//! - `config_generation` gauge
93//! - `jwks_refresh_total{provider}` counter
94//! - `dwara_rate_limiter_evictions_total` gauge (scrape-time snapshot
95//!   of the rate-limit engine's monotonic eviction counter, aggregated
96//!   over every compiled rule; resets when a reload rebuilds the
97//!   engine; a gauge rather than a counter so the hot check path stays
98//!   free of registry coupling)
99//! - `dwara_rate_limiter_live_keys` gauge (live per-key cells across
100//!   every compiled rule, approximate, bounded by the sharded store
101//!   cap; aggregate and unlabeled — cardinality is never per key)
102//! - `dwara_webhook_events_total{kind,outcome}` counter (DW-044):
103//!   webhook deliveries by event kind and outcome — `delivered` (2xx
104//!   on some attempt), `failed` (retries exhausted / non-transient
105//!   answer / budget spent), `dropped` (never tried: envelope over the
106//!   byte cap, or delivery concurrency saturated). Both labels are
107//!   closed sets, so cardinality is bounded by construction.
108//! - `dwara_events_dropped_total` / `dwara_events_emitted_total`
109//!   gauges (DW-044, scrape-time snapshots of the event bus's
110//!   monotonic counters — gauges rather than counters so the emit path
111//!   stays a plain atomic with zero registry coupling, the same model
112//!   as the rate-limiter eviction gauge): events dropped at EMIT time
113//!   (full queue / no deliverer) and events handed to the queue.
114//! - `dwara_access_records_streamed_total{outcome}` counter (DW-121):
115//!   access records counted per flushed-batch delivery outcome —
116//!   `delivered` / `failed` (tried and not taken) / `dropped`
117//!   (per-record byte cap) — a closed three-value label set, counted
118//!   by the stream flusher, never the request path.
119//! - `dwara_access_records_offered_total` /
120//!   `dwara_access_records_dropped_total` gauges (DW-121, scrape-time
121//!   snapshots of the record stream's monotonic counters, the same
122//!   gauge-not-counter model as the event bus): records offered at
123//!   request completion and records dropped at OFFER time (channel
124//!   full / stream disabled).
125//! - `dwara_quota_denied_total{consumer,budget}` counter (DW-033) —
126//!   requests refused by a consumer request budget (429), by consumer
127//!   (the quota-configured set, config-bounded) and binding budget
128//!   (`daily`/`monthly`, a closed set). Distinct from
129//!   `rate_limited_total`: budgets are not rates.
130//! - `dwara_quota_used{consumer,budget}` / `dwara_quota_limit{consumer,
131//!   budget}` gauges (DW-033, scrape-time snapshots of the state
132//!   store's quota counters and the config caps — the same snapshot
133//!   gauge model as the rate-limiter live-keys gauge).
134//! - `dwara_admission_queued_total{outcome}` counter (DW-053) —
135//!   admission queue outcomes by closed set `admitted` (a permit was
136//!   acquired after queueing), `timeout` (the queue timeout expired;
137//!   shed with 503), `queue_full` (the queue was at capacity; shed
138//!   immediately with 503).
139//! - `dwara_admission_queue_depth` gauge (DW-053) — current number of
140//!   requests waiting in the admission queue for a concurrency permit.
141//! - `dwara_license_status` gauge (DW-032) — 0 = no license (OSS mode),
142//!   1 = valid, 2 = expired within grace period, 3 = expired past grace
143//!   period (degraded to OSS). Set at startup and on reload; no labels.
144//! - `dwara_dns_discovery_endpoints{upstream}` gauge (DW-042) — current
145//!   resolved endpoint count for DNS-discovered upstreams. The `upstream`
146//!   label is config-bounded; cardinality is never per resolved address.
147//! - `dwara_dns_discovery_refresh_total{upstream}` counter (DW-042) — DNS
148//!   discovery refresh attempts, by upstream (config-bounded label).
149//! - `dwara_dns_discovery_refresh_failures_total{upstream}` counter
150//!   (DW-042) — DNS discovery refresh failures, by upstream.
151//! - `dwara_mcp_sessions_total{state}` counter (DW-087) — MCP session
152//!   lifecycle transitions, by state (`initialized`, `closed`,
153//!   `expired`). A closed three-value label set, so cardinality is
154//!   three series total.
155//! - `dwara_mcp_tool_calls_total{tool,status}` counter (DW-087) — MCP
156//!   tool calls, by tool (config-bounded) and status (`success`,
157//!   `error`, `denied`). No consumer label (cardinality rule).
158//! - `dwara_mcp_tool_duration_seconds{tool}` histogram (DW-087) — MCP
159//!   tool call duration (authz check through upstream response), by
160//!   tool (config-bounded label).
161//! - `dwara_a2a_requests_total{agent,outcome}` counter (DW-114) — A2A
162//!   (agent-to-agent) requests, by agent (config-bounded) and outcome
163//!   (`success`, `error`, `stubbed`). No consumer label (cardinality
164//!   rule).
165//! - `dwara_a2a_sessions_total{state}` counter (DW-114) — A2A session
166//!   lifecycle transitions, by state (`initialized`, `closed`,
167//!   `expired`). A closed three-value label set (mirrors MCP), so
168//!   cardinality is three series total.
169//! - `dwara_tls_pq_handshakes_total{result}` counter (DW-105) —
170//!   post-quantum TLS handshake outcomes, by result (`success`,
171//!   `fallback`, `disabled`). A closed three-value label set, so
172//!   cardinality is three series total.
173//! - `dwara_graphql_requests_total{route,outcome}` counter (DW-099) —
174//!   GraphQL check outcomes, by route (config-bounded) and outcome
175//!   (`allowed`, `depth_exceeded`, `complexity_exceeded`,
176//!   `persisted_query_required` — a closed four-value set).
177//! - `dwara_graphql_depth{route}` gauge (DW-099) — last query depth for
178//!   a GraphQL-configured route (config-bounded label).
179//! - `dwara_nano_service_requests_total{route,outcome}` counter (DW-106)
180//!   — nano-service (WASM route handler) request outcomes, by route
181//!   (config-bounded) and outcome (`success`, `error`, `timeout` — a
182//!   closed three-value set). No consumer label (cardinality rule).
183//! - `dwara_nano_service_duration_seconds{route}` histogram (DW-106) —
184//!   nano-service `handle` call duration, by route (config-bounded
185//!   label).
186//! - `dwara_plugin_failures_total{name,reason}` counter (DW-157) —
187//!   plugin dispatch failures, by plugin name (config-bounded) and
188//!   reason (`crashed`, `disabled`, `not_loaded`, `not_registered`,
189//!   `instantiate_failed`, `trap`, `body_too_large`,
190//!   `response_stream_ended` — a closed set).
191//!   One increment per request the plugin chain answered with an error
192//!   (a plugin short-circuit via `send_http_response` is a plugin
193//!   DECISION, not a failure, and is not counted; the access-log
194//!   `plugin_short_circuit` flag marks those).
195//! - `dwara_plugin_total{state}` gauge (DW-158) — plugins loaded per
196//!   lifecycle state (`healthy`, `crashed`, `disabled`, `not_loaded`,
197//!   `not_registered` — a closed five-value set over the generation's
198//!   declared plugins, the same enumeration `GET /plugins` serves).
199//!   Refreshed at publish time (every config generation); state changes
200//!   that happen between publishes (a crash counted at request time)
201//!   land on the next publish or are visible per-request in
202//!   `dwara_plugin_failures_total`.
203//! - `dwara_graphql_complexity{route}` gauge (DW-099) — last query
204//!   complexity for a GraphQL-configured route (config-bounded label).
205//! - `dwara_l4_connections_total{listener,protocol}` counter (DW-103) —
206//!   L4 connections proxied, by listener (config-bounded) and protocol
207//!   (`tcp`, `udp` — a closed two-value set). Counted once per accepted
208//!   L4 connection (before the splice is established).
209//! - `dwara_l4_connection_duration_seconds{listener}` histogram
210//!   (DW-103) — L4 connection duration (splice establishment through
211//!   close), by listener (config-bounded label).
212//! - `dwara_ai_request_duration_seconds{provider,route}` histogram
213//!   (AI-07) — AI request latency (request parse through response
214//!   translation), by provider and route (both config-bounded). Observed
215//!   once per completed AI request (success and provider/translation
216//!   errors alike; client-side rejections never reach a provider).
217//! - `dwara_ai_tokens_per_request{provider,model,kind}` histogram
218//!   (AI-07) — distribution of provider-reported tokens per request, by
219//!   provider (config-bounded), provider_model (config-bounded), and
220//!   kind (the closed set `prompt` | `completion`). Provider-reported
221//!   only; the gateway never estimates. No consumer label (cardinality).
222//!
223//! ## OTel GenAI semantic conventions (AI-07)
224//!
225//! AI request spans carry the OpenTelemetry GenAI semantic-convention
226//! `gen_ai.*` attributes (<https://opentelemetry.io/docs/specs/semconv/gen-ai/>):
227//! `gen_ai.system` (the provider kind), `gen_ai.request.model`
228//! (the client alias), `gen_ai.response.model` (the provider model that
229//! served), `gen_ai.request.{max_tokens,temperature,top_p}`, and the
230//! provider-reported `gen_ai.usage.{prompt_tokens,completion_tokens,
231//! total_tokens}`, `gen_ai.response.finish_reasons`, and
232//! `gen_ai.response.id`. The helpers [`record_gen_ai_request_attrs`] and
233//! [`record_gen_ai_response_attrs`] record them onto a span created with
234//! every `gen_ai.*` field declared `Empty` (the span lives in
235//! `dataplane::ai_proxy::serve_ai`).
236//!
237//! ## Error envelope (section 4.19)
238//!
239//! Every gateway-generated non-success body — including the reserved
240//! `/healthz`/`/readyz` responses, which are aligned to the same shape —
241//! is `{"error":{"code","message","request_id"}}`. Messages never leak
242//! upstream internals: classification strings only ("upstream
243//! unavailable", not hyper error text).
244//!
245//! ## Request IDs
246//!
247//! An inbound `X-Request-Id` is respected when it is printable ASCII of
248//! at most 128 bytes (anything else is replaced — a hostile ID must not
249//! smuggle control bytes into logs); otherwise a process-unique ID is
250//! generated (wall-clock nanoseconds + per-process counter — cheaper
251//! than a UUID, no extra dependency). The ID is echoed on the response
252//! as `X-Request-Id` and available to every span and log line.
253//!
254//! ## Redaction rules (hard requirements)
255//!
256//! - Paths are logged WITHOUT query strings.
257//! - No Authorization / Proxy-Authorization / Cookie / Set-Cookie /
258//!   X-API-Key VALUES are ever emitted by this module or the tracing
259//!   calls it fronts; field lists below are exhaustive.
260//! - JWKS bodies, credentials, and keys are never logged.
261
262use std::sync::atomic::{AtomicU64, Ordering};
263use std::time::Duration;
264
265use hyper::header::{HeaderMap, HeaderName, HeaderValue};
266use prometheus::{
267    Encoder, GaugeVec, HistogramOpts, HistogramVec, IntCounter, IntCounterVec, IntGauge,
268    IntGaugeVec, Opts, Registry, TextEncoder,
269};
270
271/// Request header carrying the correlation ID (inbound respected,
272/// outbound always set by the gateway).
273pub const X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
274
275/// Request header carrying the business correlation ID (DW-093):
276/// inbound respected (falls back to `X-Request-Id`), outbound echoed
277/// on the response so a client can tie a reply to its journey across
278/// multiple gateway requests.
279pub const X_CORRELATION_ID: HeaderName = HeaderName::from_static("x-correlation-id");
280
281/// Extension inserted by the listener frontend (dwara-bin) naming the
282/// listener that accepted the request; absent when `proxy::handle` is
283/// driven directly (tests), in which case the label is "unknown".
284#[derive(Clone, Debug)]
285pub struct ListenerLabel(pub std::sync::Arc<str>);
286
287/// Histogram buckets for `request_duration_seconds`: sub-millisecond
288/// precision is noise at gateway scale; 10s covers every classified
289/// timeout (default connect timeout is 5s).
290const DURATION_BUCKETS: &[f64] = &[
291    0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
292];
293
294/// Histogram buckets for `dwara_ai_first_token_seconds` (DW-077):
295/// first-token latency is the metric streaming consumers feel, so the
296/// low end is finer than DURATION_BUCKETS; 30s covers a cold provider
297/// queue without a tail bucket that swallows real regressions.
298const FIRST_TOKEN_BUCKETS: &[f64] = &[
299    0.005, 0.01, 0.025, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1.0, 1.5, 2.5, 5.0, 10.0, 30.0,
300];
301
302/// Histogram buckets for `dwara_ai_tokens_per_request` (AI-07): token
303/// counts span a few (tiny prompts) to hundreds of thousands (long
304/// context); an exponential-ish scale keeps both ends observable without
305/// a single bucket that swallows the long tail.
306const TOKENS_PER_REQUEST_BUCKETS: &[f64] = &[
307    1.0, 8.0, 32.0, 128.0, 256.0, 512.0, 1024.0, 2048.0, 4096.0, 8192.0, 16384.0, 32768.0, 65536.0,
308    131072.0,
309];
310
311/// Prometheus status-class label ("2xx", "5xx", ...).
312///
313/// PERF: a `&'static str` per class, not a `format!` — this fires on
314/// the per-request and per-attempt metric recording paths, and the
315/// label text is one of ten fixed spellings.
316pub fn status_class(status: u16) -> &'static str {
317    match status / 100 {
318        0 => "0xx",
319        1 => "1xx",
320        2 => "2xx",
321        3 => "3xx",
322        4 => "4xx",
323        5 => "5xx",
324        6 => "6xx",
325        7 => "7xx",
326        8 => "8xx",
327        _ => "9xx",
328    }
329}
330
331/// The accumulated access-log record for one request. The proxy fills
332/// fields as the request progresses; `finish` stamps status/duration.
333#[derive(Debug)]
334pub struct AccessRecord {
335    pub request_id: String,
336    /// The correlation ID (DW-093): the inbound `X-Correlation-Id`
337    /// when present and valid, else the `request_id` (the fallback
338    /// so every request carries a non-empty correlation handle for
339    /// the journey/funnel query). Stored in the analytics `raw`
340    /// table; echoed on the response as `X-Correlation-Id`.
341    pub correlation_id: String,
342    pub method: String,
343    /// Request path WITHOUT the query string (redaction).
344    pub path: String,
345    pub listener: String,
346    /// Matched route name, or "unrouted" for 404s.
347    pub route: String,
348    /// Authenticated consumer, or "anonymous".
349    pub consumer: String,
350    pub upstream: Option<String>,
351    pub endpoint: Option<String>,
352    pub attempts: u32,
353    pub rate_limited: bool,
354    pub broken: bool,
355    pub shed: bool,
356    /// A plugin chain answered this request instead of the route's
357    /// action (DW-157): a `send_http_response` short-circuit or a
358    /// fail-closed plugin error (500 `plugin_unavailable` /
359    /// `plugin_failed` / `plugin_body_too_large`).
360    pub plugin_short_circuit: bool,
361    pub status: u16,
362    pub duration_ms: f64,
363    /// Inbound request body bytes (PERF-12, #209): the declared
364    /// `Content-Length` when present, 0 for streamed/chunked request
365    /// bodies (counting streamed request bytes would require wrapping
366    /// the upstream-forwarded body; the response-side count is the
367    /// primary signal for capacity planning and top-talker reports).
368    pub bytes_in: u64,
369    /// Outbound response body bytes (PERF-12, #209): counted frame-by-
370    /// frame as the response body streams to the client, WITHOUT
371    /// buffering. An `Arc<AtomicU64>` so the `CountingBody` wrapper
372    /// can increment it from `poll_frame` while the access record is
373    /// held by the caller; the final value is read when the body
374    /// completes and the deferred access log / analytics are emitted.
375    pub bytes_out: std::sync::Arc<std::sync::atomic::AtomicU64>,
376    /// Custom analytics dimensions (DW-043): config-declared
377    /// header-sourced tags, captured at record creation. Analytics-
378    /// only — deliberately NOT part of `emit_access`'s redacted
379    /// field list.
380    pub custom: Vec<(String, String)>,
381}
382
383impl AccessRecord {
384    pub fn new(request_id: String, method: String, path: String, listener: String) -> Self {
385        AccessRecord {
386            request_id,
387            correlation_id: String::new(),
388            method,
389            path,
390            listener,
391            route: "unrouted".to_string(),
392            consumer: "anonymous".to_string(),
393            upstream: None,
394            endpoint: None,
395            attempts: 0,
396            rate_limited: false,
397            broken: false,
398            shed: false,
399            plugin_short_circuit: false,
400            status: 0,
401            duration_ms: 0.0,
402            bytes_in: 0,
403            bytes_out: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
404            custom: Vec::new(),
405        }
406    }
407}
408
409/// Emit one access-log event (one JSON line under the binary's
410/// subscriber). The field list is exhaustive and redacted by
411/// construction: no headers, no query string, no credentials.
412/// `bytes_in`/`bytes_out` (PERF-12, #209): `bytes_in` is the declared
413/// `Content-Length` (0 for chunked); `bytes_out` is the frame-counted
414/// response body size (read from the atomic the `CountingBody`
415/// wrapper updated as frames streamed to the client).
416pub fn emit_access(rec: &AccessRecord) {
417    let bytes_out = rec.bytes_out.load(std::sync::atomic::Ordering::Relaxed);
418    tracing::info!(
419        target: "dwara::access",
420        request_id = %rec.request_id,
421        method = %rec.method,
422        path = %rec.path,
423        status = rec.status,
424        duration_ms = rec.duration_ms,
425        route = %rec.route,
426        consumer = %rec.consumer,
427        upstream = rec.upstream.as_deref().unwrap_or(""),
428        endpoint = rec.endpoint.as_deref().unwrap_or(""),
429        attempts = rec.attempts,
430        rate_limited = rec.rate_limited,
431        broken = rec.broken,
432        shed = rec.shed,
433        plugin_short_circuit = rec.plugin_short_circuit,
434        bytes_in = rec.bytes_in,
435        bytes_out = bytes_out,
436        "access"
437    );
438}
439
440/// The unified error body (section 4.19): a JSON object an operator can
441/// grep and a client can correlate. `code` is a stable machine token,
442/// `message` a human string with no upstream internals, `request_id`
443/// ties the response to the trace/access log.
444pub fn envelope_body(code: &str, message: &str, request_id: &str) -> bytes::Bytes {
445    // Manual JSON formatting avoids the serde_json::Value intermediate
446    // allocation (the json! macro builds a Value tree, then to_string()
447    // serializes it back — two heap allocations). The envelope shape is
448    // fixed; we pre-allocate one String and JSON-escape each field.
449    fn escape_into(out: &mut String, s: &str) {
450        for c in s.chars() {
451            match c {
452                '"' => out.push_str("\\\""),
453                '\\' => out.push_str("\\\\"),
454                '\n' => out.push_str("\\n"),
455                '\r' => out.push_str("\\r"),
456                '\t' => out.push_str("\\t"),
457                c if c < ' ' => out.push_str(&format!("\\u{:04x}", c as u32)),
458                c => out.push(c),
459            }
460        }
461    }
462    let mut buf = String::with_capacity(64 + code.len() + message.len() + request_id.len());
463    buf.push_str(r#"{"error":{"code":""#);
464    escape_into(&mut buf, code);
465    buf.push_str(r#"","message":""#);
466    escape_into(&mut buf, message);
467    buf.push_str(r#"","request_id":""#);
468    escape_into(&mut buf, request_id);
469    buf.push_str(r#""}}"#);
470    bytes::Bytes::from(buf)
471}
472
473/// Validate an inbound `X-Request-Id` value: printable ASCII (0x20-0x7E),
474/// at most 128 bytes. Anything else (control bytes, UTF-8 multibyte,
475/// overlong) is rejected so hostile IDs cannot smuggle control
476/// characters into logs.
477pub fn valid_inbound_request_id(v: &[u8]) -> bool {
478    !v.is_empty() && v.len() <= 128 && v.iter().all(|b| (0x20..=0x7e).contains(b))
479}
480
481/// Resolve the request's correlation ID: a valid inbound `X-Request-Id`
482/// verbatim, else a generated one (`req-` + hex wall-clock nanoseconds +
483/// hex process counter; unique per process, no dependency pulled in for
484/// randomness — request IDs are correlation handles, not secrets).
485pub fn resolve_request_id(headers: &HeaderMap) -> String {
486    if let Some(v) = headers.get(&X_REQUEST_ID) {
487        if valid_inbound_request_id(v.as_bytes()) {
488            if let Ok(s) = v.to_str() {
489                return s.to_string();
490            }
491        }
492    }
493    generate_request_id()
494}
495
496/// Per-process request-ID counter (the low bits of the wall clock are
497/// already unique per nanosecond; the counter disambiguates coarse
498/// clocks and multi-ID calls within one nanosecond).
499static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
500
501/// Public for testing the request-id generation contract.
502pub fn generate_request_id() -> String {
503    let n = std::time::SystemTime::now()
504        .duration_since(std::time::UNIX_EPOCH)
505        .map(|d| d.as_nanos())
506        .unwrap_or(0);
507    let c = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
508    format!("req-{:016x}-{:06x}", n, c & 0xff_ffff)
509}
510
511/// Cheap sampling PRNG: a Weyl-sequence over one atomic (add a large
512/// odd constant, use the high bits of the product). Not cryptographic —
513/// it only decides whether an access-log line is emitted.
514struct SampleRng {
515    state: AtomicU64,
516}
517
518impl SampleRng {
519    fn new() -> Self {
520        let seed = std::time::SystemTime::now()
521            .duration_since(std::time::UNIX_EPOCH)
522            .map(|d| d.as_nanos() as u64)
523            .unwrap_or(0x9e37_79b9_7f4a_7c15);
524        SampleRng {
525            state: AtomicU64::new(seed | 1),
526        }
527    }
528
529    fn next_f64(&self) -> f64 {
530        let x = self
531            .state
532            .fetch_add(0x9e37_79b9_7f4a_7c15, Ordering::Relaxed);
533        // Multiply-shift normalization into [0, 1).
534        ((x.wrapping_mul(0x9e37_79b9_7f4a_7c15)) >> 11) as f64 / (1u64 << 53) as f64
535    }
536}
537
538/// Per-dataplane observability state: the metric families plus the
539/// access-log sampling knob. One instance per `DataPlane`, so parallel
540/// tests never share a registry (duplicate-metric registration errors).
541pub struct Observability {
542    registry: Registry,
543    requests_total: IntCounterVec,
544    request_duration: HistogramVec,
545    upstream_attempts_total: IntCounterVec,
546    retries_total: IntCounterVec,
547    /// DW-063: hedge copies sent (label: upstream). Counts every
548    /// speculative duplicate the hedge timer fired, regardless of
549    /// whether it won the race.
550    hedge_sent_total: IntCounterVec,
551    /// DW-062: mirror (shadow traffic) requests sent, by upstream.
552    mirror_sent_total: IntCounterVec,
553    rate_limited_total: IntCounterVec,
554    shed_total: IntCounterVec,
555    policy_dry_run_total: IntCounterVec,
556    breaker_state: IntGaugeVec,
557    endpoint_health: IntGaugeVec,
558    fail_open_picks: IntGaugeVec,
559    jwks_refresh_total: IntCounterVec,
560    active_requests: IntGauge,
561    config_generation: IntGauge,
562    /// #132: rate-limiter eviction/live-key snapshot gauges — aggregate,
563    /// unlabeled (config-bounded cardinality: never per key).
564    rate_limiter_evictions: IntGauge,
565    rate_limiter_live_keys: IntGauge,
566    /// DW-044: webhook delivery outcomes by (kind, outcome) — both
567    /// labels closed sets (the event kinds and the three outcomes), so
568    /// cardinality is bounded by construction.
569    webhook_events_total: IntCounterVec,
570    /// DW-044: event-bus emit/drop snapshot gauges (see the module
571    /// docs for the gauge-not-counter rationale).
572    events_dropped: IntGauge,
573    events_emitted: IntGauge,
574    /// DW-121: access records counted per flushed-batch delivery
575    /// outcome — a CLOSED label set (`delivered`, `failed`, `dropped`),
576    /// so the family's cardinality is three series total. Counted by
577    /// the flusher (never the request path).
578    access_records_streamed_total: IntCounterVec,
579    /// DW-121: access records offered to the stream channel at request
580    /// completion; scrape-time snapshot of the stream's monotonic
581    /// counter (the emit path bumps a plain atomic — the same
582    /// gauge-not-counter model as the event bus).
583    access_records_offered: IntGauge,
584    /// DW-121: access records dropped at OFFER time (stream channel
585    /// full or the stream disabled); scrape-time snapshot, same model
586    /// as `dwara_events_dropped_total`.
587    access_records_dropped: IntGauge,
588    /// REL-10 (#221): access records dropped by sampled degradation
589    /// (probabilistic pre-full drop). Scrape-time snapshot of the
590    /// analytics store's counter.
591    access_records_sampled_dropped: IntGauge,
592    /// DW-039: WebSocket policy decisions — a CLOSED label set
593    /// (`origin_denied`, `rate_closed`) over the route label space
594    /// (config-bounded, the same cardinality class as
595    /// `requests_total`).
596    websocket_policy_total: IntCounterVec,
597    /// DW-040: split-service dispatch decisions — the `service` and
598    /// `upstream` labels are both config-declared names (bounded by
599    /// the split configuration), so canary share is directly
600    /// readable as `upstream` share within a `service`.
601    split_picks_total: IntCounterVec,
602    /// DW-040: sticky sessions minted (affinity cookies set on first
603    /// response). Plain counter — no label space.
604    sticky_sessions_total: IntCounter,
605    /// DW-037: response-cache lookup outcomes — a CLOSED label set
606    /// (`hit`, `stale`, `miss`, `bypass`), one increment per request on
607    /// a cache-configured route, decided at lookup.
608    cache_lookups_total: IntCounterVec,
609    /// DW-037: response-cache store outcomes — CLOSED set (`stored`,
610    /// `vetoed`, `over_cap`, `error`), one increment per cacheable
611    /// fetch that reached the store stage.
612    cache_stores_total: IntCounterVec,
613    /// DW-037: responses served from a stored body after the upstream
614    /// answered a conditional revalidation with 304 (the "unchanged"
615    /// path; plain counter — no label space to close).
616    cache_revalidated_total: IntCounter,
617    /// DW-037: purge operations — CLOSED set (`route`, `all`).
618    cache_purges_total: IntCounterVec,
619    /// DW-037: approximate live cache entries (scrape-time snapshot
620    /// walk of the store's `entry_count`, the same gauge model as the
621    /// rate-limiter live-keys gauge).
622    cache_entries: IntGauge,
623    /// DW-038: cache misses that became the coalescing LEADER (their
624    /// own upstream fetch ran while followers could attach). Plain
625    /// counter — no label space to close.
626    coalescing_leaders_total: IntCounter,
627    /// DW-038: coalescing follower resolutions — a CLOSED label set
628    /// (`served`, `fell_back_timeout`, `fell_back_unshared`,
629    /// `fell_back_epoch`), so the family's cardinality is four series
630    /// total. `served` = the leader's stored outcome replayed;
631    /// `fell_back_timeout` = the wait bound expired; `fell_back_unshared`
632    /// = the leader finished without a storable outcome (vetoed,
633    /// non-200, error — the follower fetched on its own);
634    /// `fell_back_epoch` = a purge/config change invalidated the
635    /// generation mid-flight (checked first; the follower fetched on
636    /// its own).
637    coalescing_followers_total: IntCounterVec,
638    /// DW-038: upstream calls avoided by coalescing (one per `served`
639    /// follower — the direct answer to "is coalescing paying for
640    /// itself").
641    coalescing_saved_upstream_calls_total: IntCounter,
642    /// DW-038: requests currently parked as coalescing followers.
643    coalescing_waiters: IntGauge,
644    /// DW-033: quota denials by (consumer, budget) — the consumer label
645    /// space is the quota-configured consumer set (config-bounded), and
646    /// `budget` is the closed set `daily`/`monthly`.
647    quota_denied_total: IntCounterVec,
648    /// DW-033: requests counted in each budget's CURRENT window
649    /// (scrape-time snapshot of the state store's quota counters, the
650    /// same gauge model as the rate-limiter live-keys gauge).
651    quota_used: IntGaugeVec,
652    /// DW-033: each budget's configured cap (scrape-time, same snapshot
653    /// walk as `quota_used`; exported so `used / limit` alerting needs
654    /// no config join).
655    quota_limit: IntGaugeVec,
656    /// DW-052: the SLO burn-rate collector (per-route windowed state
657    /// plus the two exported families; values refresh at scrape time).
658    slo: std::sync::Arc<SloState>,
659    /// DW-053: admission queue outcomes — a CLOSED label set
660    /// (`admitted`, `timeout`, `queue_full`), one increment per
661    /// request that was queued (or rejected at the queue door).
662    admission_queued_total: IntCounterVec,
663    /// DW-053: current number of requests waiting in the admission
664    /// queue for a concurrency permit. A gauge set from an atomic on
665    /// the dataplane (the hot path stays free of registry coupling,
666    /// the same model as the coalescing-waiters gauge).
667    admission_queue_depth: IntGauge,
668    /// DW-051: WAF-lite inspection outcomes — a CLOSED label set
669    /// (`blocked`, `logged`, `passed`) over the route and filter label
670    /// spaces (both config-bounded). `blocked` = a match was found and
671    /// the request was rejected with 403; `logged` = a dry-run match
672    /// (the request was allowed); `passed` = no match (counted once per
673    /// inspected request, filter label `all`).
674    waf_total: IntCounterVec,
675    /// DW-090: anomaly-scoring inspection outcomes — a CLOSED label set
676    /// (`blocked`, `logged`, `passed`) over the route label space
677    /// (config-bounded). `blocked` = the score was at or above the
678    /// threshold and the request was rejected with 403; `logged` = a
679    /// dry-run match (the request was allowed); `passed` = the score was
680    /// below the threshold (counted once per inspected request).
681    anomaly_total: IntCounterVec,
682    /// DW-099: GraphQL check outcomes — a CLOSED label set (`allowed`,
683    /// `depth_exceeded`, `complexity_exceeded`,
684    /// `persisted_query_required`) over the route label space
685    /// (config-bounded). One increment per GraphQL-configured request.
686    graphql_requests_total: IntCounterVec,
687    /// DW-099: last query depth for a GraphQL-configured route (gauge;
688    /// config-bounded route label).
689    graphql_depth: IntGaugeVec,
690    /// DW-099: last query complexity for a GraphQL-configured route
691    /// (gauge; config-bounded route label).
692    graphql_complexity: IntGaugeVec,
693    /// DW-032: license status gauge — 0 = no license (OSS), 1 = valid,
694    /// 2 = expired within grace, 3 = expired past grace (degraded to
695    /// OSS). Set once at startup and on every reload; no labels (closed
696    /// four-value set, so cardinality is one series).
697    license_status: IntGauge,
698    /// DW-042: DNS discovery endpoint count — a gauge of the current
699    /// resolved endpoint count per upstream (config-bounded `upstream`
700    /// label; never per resolved address). Scrape-time snapshot setter
701    /// driven by the discovery task.
702    dns_discovery_endpoints: IntGaugeVec,
703    /// DW-042: DNS discovery refresh attempts — a counter of resolution
704    /// cycles per upstream (config-bounded `upstream` label).
705    dns_discovery_refresh_total: IntCounterVec,
706    /// DW-042: DNS discovery refresh failures — a counter of failed
707    /// resolution cycles per upstream (config-bounded `upstream` label).
708    dns_discovery_refresh_failures_total: IntCounterVec,
709    /// DW-054: this instance's current convergence generation, by
710    /// instance id. The instance label is config-bounded (one series
711    /// per process); set by the convergence coordinator after every
712    /// local or remote publish.
713    config_convergence_generation: IntGaugeVec,
714    /// DW-054: total instances the convergence backend currently
715    /// reports (the cluster view the drift check reads). Aggregate and
716    /// unlabeled -- cardinality is one series.
717    config_convergence_instances: IntGauge,
718    /// DW-054: drift flag -- 1 when one or more instances serve a
719    /// different config hash than the majority, 0 when converged.
720    /// Aggregate and unlabeled -- cardinality is one series.
721    config_convergence_drift: IntGauge,
722    /// DW-054: convergence refresh attempts (polls of the backend).
723    /// Aggregate counter -- cardinality is one series.
724    config_convergence_refresh_total: IntCounter,
725    /// DW-054: convergence refresh failures (backend unreachable or
726    /// returned malformed data). Aggregate counter -- cardinality is
727    /// one series.
728    config_convergence_refresh_failures_total: IntCounter,
729    /// DW-075: AI route requests, by provider/route/outcome. The
730    /// provider label is the `ai.providers[].name` (config-bounded);
731    /// outcomes: success, provider_error, transport_error,
732    /// translation_error. DW-076 adds the canary `version` label
733    /// (config-bounded; "default" for non-canary aliases).
734    /// Client-side rejections (malformed body, unknown model) are NOT
735    /// counted here — they never reach a provider and are already
736    /// visible in requests_total's status classes.
737    ai_requests_total: IntCounterVec,
738    /// DW-075: provider-reported token usage, by provider and
739    /// direction (prompt|completion), with the canary `version` label
740    /// (DW-076). Provider-reported only (the locked M4 decision: the
741    /// gateway never estimates).
742    ai_tokens_total: IntCounterVec,
743    /// DW-079: total priced AI spend in micro-USD, by provider and
744    /// provider_model (config-bounded labels — no consumer label,
745    /// cardinality rule). Priced through the dataplane's compiled
746    /// pricing table; 0 for unknown models.
747    ai_cost_micros_total: IntCounterVec,
748    /// DW-AI-03: count of requests served by a model with no pricing
749    /// entry, by provider_model. Incremented only under the `alert`
750    /// unknown-model policy (the `allow` policy is silent; the
751    /// `fail_closed` policy rejects before recording). The model label
752    /// is the provider model identifier (config-bounded).
753    ai_unknown_pricing_total: IntCounterVec,
754    /// DW-078: AI budget denials, by kind (tokens | cost). No consumer
755    /// label (cardinality); the consumer is in the access log line.
756    ai_budget_denied_total: IntCounterVec,
757    /// DW-084: AI governance denials, by reason (config-bounded label —
758    /// the denial reason string, not the consumer or model). No
759    /// consumer/model labels (cardinality rule); the consumer and
760    /// model are in the governance audit table and the access log.
761    ai_governance_denied_total: IntCounterVec,
762    /// DW-082: AI guardrail blocks, by rule name and kind
763    /// (config-bounded labels — the rule name and the kind/phase
764    /// string). No consumer label (cardinality); the consumer is in
765    /// the access log.
766    ai_guardrail_blocked_total: IntCounterVec,
767    /// DW-083: AI semantic cache hits, by model alias
768    /// (config-bounded label). No consumer label (cardinality); the
769    /// consumer is in the access log.
770    ai_semantic_cache_hits_total: IntCounterVec,
771    /// DW-083: AI semantic cache misses, by model alias
772    /// (config-bounded label). No consumer label (cardinality); the
773    /// consumer is in the access log.
774    ai_semantic_cache_misses_total: IntCounterVec,
775    /// DW-085: AI routing-policy FallbackChain escalations (complex
776    /// prompt -> expensive model), by policy name (config-bounded
777    /// label). No consumer label (cardinality); the consumer is in
778    /// the access log.
779    ai_routing_policy_escalations_total: IntCounterVec,
780    /// DW-085: AI routing-policy FallbackChain cheap-model
781    /// selections (simple prompt -> cheap model), by policy name
782    /// (config-bounded label). No consumer label (cardinality); the
783    /// consumer is in the access log.
784    ai_routing_policy_cheap_total: IntCounterVec,
785    /// DW-085: AI routing-policy LatencyCost selections, by policy
786    /// name (config-bounded label). No consumer label (cardinality);
787    /// the consumer is in the access log.
788    ai_routing_policy_latency_cost_selections_total: IntCounterVec,
789    /// DW-086: AI experiment (A/B test) variant selections, by
790    /// experiment and variant (config-bounded labels). No consumer
791    /// label (cardinality); the consumer is in the access log and
792    /// the analytics assignment table.
793    ai_experiment_variant_selections_total: IntCounterVec,
794    /// DW-077: forwarded streaming chunks (SSE frames), by provider.
795    /// One per client-visible delta chunk — the per-chunk
796    /// observability of the streaming path.
797    ai_stream_chunks_total: IntCounterVec,
798    /// DW-077: time to the FIRST forwarded chunk of a stream, by
799    /// provider (request send to first delta), in seconds.
800    ai_first_token_seconds: HistogramVec,
801    /// DW-077: total streaming duration (first request byte to stream
802    /// end), by provider, in seconds.
803    ai_stream_duration_seconds: HistogramVec,
804    /// AI-07: AI request latency (request parse through response
805    /// translation), by provider (config-bounded) and route
806    /// (config-bounded), in seconds. Observed once per completed AI
807    /// request (success and provider/translation errors alike; client-
808    /// side rejections never reach a provider and are not observed).
809    ai_request_duration_seconds: HistogramVec,
810    /// AI-07: distribution of provider-reported tokens per request, by
811    /// provider (config-bounded), provider_model (config-bounded), and
812    /// kind (the CLOSED set `prompt` | `completion`). Observed once per
813    /// request that reported usage (provider-reported only; the gateway
814    /// never estimates). No consumer label (cardinality rule).
815    ai_tokens_per_request: HistogramVec,
816    /// DW-087: MCP session lifecycle transitions, by state
817    /// (`initialized`, `closed`, `expired`). A CLOSED three-value label
818    /// set, so the family's cardinality is three series total.
819    mcp_sessions_total: IntCounterVec,
820    /// DW-087: MCP tool calls, by tool and status. `tool` is the tool
821    /// name (config-bounded); `status` is the CLOSED set `success`,
822    /// `error`, `denied`. No consumer label (cardinality rule); the
823    /// consumer is in the access log and the analytics table.
824    mcp_tool_calls_total: IntCounterVec,
825    /// DW-087: MCP tool call duration (authz check through upstream
826    /// response), by tool (config-bounded label), in seconds.
827    mcp_tool_duration_seconds: HistogramVec,
828    /// DW-114: A2A (agent-to-agent) requests, by agent and outcome.
829    /// `agent` is the config-declared agent name (config-bounded);
830    /// `outcome` is the CLOSED set `success`, `error`, `stubbed`. No
831    /// consumer label (cardinality rule); the consumer is in the
832    /// access log.
833    a2a_requests_total: IntCounterVec,
834    /// DW-114: A2A session lifecycle transitions, by state
835    /// (`initialized`, `closed`, `expired`). A CLOSED three-value
836    /// label set (mirrors MCP), so the family's cardinality is three
837    /// series total.
838    a2a_sessions_total: IntCounterVec,
839    /// DW-106: nano-service (WASM route handler) request outcomes, by
840    /// route (config-bounded) and outcome (the CLOSED set `success`,
841    /// `error`, `timeout`). No consumer label (cardinality rule); the
842    /// consumer is in the access log.
843    nano_service_requests_total: IntCounterVec,
844    /// DW-106: nano-service `handle` call duration, by route
845    /// (config-bounded label). Observed once per request (success,
846    /// error, and timeout alike).
847    nano_service_duration_seconds: HistogramVec,
848    /// DW-157: plugin dispatch failures, by plugin `name`
849    /// (config-bounded) and `reason` (the CLOSED set `crashed`,
850    /// `disabled`, `not_loaded`, `not_registered`,
851    /// `instantiate_failed`, `trap`, `body_too_large`,
852    /// `response_stream_ended`). One increment
853    /// per request the plugin chain answered with an error; a plugin's
854    /// deliberate `send_http_response` short-circuit is a decision, not
855    /// a failure, and is not counted.
856    plugin_failures_total: IntCounterVec,
857    plugin_callouts_total: IntCounterVec,
858    /// DW-158: plugins per lifecycle state — the CLOSED label set
859    /// `healthy`, `crashed`, `disabled`, `not_loaded`,
860    /// `not_registered` (an unregistered native filter or an
861    /// unresolved registry source is unusable exactly like a crashed
862    /// plugin — routes referencing it fail closed). One series per
863    /// state over the generation's declared plugins (the same
864    /// enumeration `GET /plugins` serves), refreshed at publish time.
865    plugin_total: IntGaugeVec,
866    /// DW-105: post-quantum TLS handshake outcomes — a CLOSED label set
867    /// (`success`, `fallback`, `disabled`), one increment per PQ-
868    /// configured handshake. `success` = the X25519+ML-KEM hybrid kx
869    /// group was negotiated; `fallback` = the client did not support
870    /// the hybrid group and a classical group was negotiated;
871    /// `disabled` = PQ was off or the experimental API was inert. No
872    /// labels beyond `result` (a closed three-value set, so the
873    /// family's cardinality is three series total).
874    tls_pq_handshakes_total: IntCounterVec,
875    /// DW-089: current adaptive rate-limit factor, by policy
876    /// (config-bounded label). A factor of 1.0 = the configured rate;
877    /// < 1.0 tightens, > 1.0 relaxes. Set on every `record_outcome`.
878    rate_limiter_adaptive_factor: GaugeVec,
879    /// DW-089: upstream origin signals received, by policy and signal
880    /// (both config-bounded labels; `signal` is the closed set
881    /// `retry_after`). Counted when the controller honors the signal.
882    rate_limiter_origin_signal_total: IntCounterVec,
883    /// DW-089: adaptive factor decreased (tightened) for a policy
884    /// (config-bounded label). Counted once per stressed update that
885    /// moved the factor down.
886    rate_limiter_adaptive_tightened_total: IntCounterVec,
887    /// DW-089: adaptive factor increased (relaxed) for a policy
888    /// (config-bounded label). Counted once per healthy update that
889    /// moved the factor up.
890    rate_limiter_adaptive_relaxed_total: IntCounterVec,
891    /// DW-091: canary promotions, by group (config-bounded label:
892    /// service name or AI alias). Counted once per promote action the
893    /// auto-canary controller applied.
894    canary_promotions_total: IntCounterVec,
895    /// DW-091: canary rollbacks, by group (config-bounded label).
896    /// Counted once per rollback action (including severe-regression
897    /// immediate rollbacks to 0).
898    canary_rollbacks_total: IntCounterVec,
899    /// DW-091: current canary weight, by group (config-bounded
900    /// label). Set on every applied adjustment.
901    canary_weight: GaugeVec,
902    /// DW-107: sidecar connections proxied, by mode and direction.
903    /// `mode` is the CLOSED set `sidecar`; `direction` is the CLOSED
904    /// set `inbound`, `outbound`. Counted once per connection the
905    /// sidecar proxies (inbound: terminate mTLS + forward to local app;
906    /// outbound: apply policies + wrap mTLS + dial remote sidecar).
907    mesh_connections_total: IntCounterVec,
908    /// DW-107: SPIFFE SVID refresh attempts, by result. `result` is
909    /// the CLOSED set `success`, `error`. Counted once per Workload
910    /// API refresh attempt (the client refreshes before expiry; an
911    /// error keeps serving the previous SVID until it expires).
912    spiffe_svid_refresh_total: IntCounterVec,
913    /// DW-107: seconds until the current SVID expires (gauge). Set on
914    /// every successful refresh; counts down to 0 as the SVID nears
915    /// expiry. The refresh loop uses this to schedule the next fetch
916    /// (at half the remaining lifetime by default).
917    spiffe_svid_expiry_seconds: IntGauge,
918    /// DW-103: L4 connections proxied, by listener (config-bounded)
919    /// and protocol (the CLOSED set `tcp`, `udp`). Counted once per
920    /// accepted L4 connection (before the splice is established).
921    l4_connections_total: IntCounterVec,
922    /// DW-103: L4 connection duration (splice establishment through
923    /// close), by listener (config-bounded label), in seconds.
924    /// Observed once per connection (forwarded and closed alike).
925    l4_connection_duration_seconds: HistogramVec,
926    /// Access-log sample rate [0.0, 1.0] as raw bits (f64 does not fit
927    /// an atomic portably); read via [`Self::access_sample`].
928    access_sample_bits: AtomicU64,
929    rng: SampleRng,
930}
931
932impl Default for Observability {
933    fn default() -> Self {
934        Self::from_env()
935    }
936}
937
938impl Observability {
939    /// Build with `DWARA_ACCESS_LOG_SAMPLE` applied (default 1.0; values
940    /// outside [0.0, 1.0] fall back to 1.0 — a malformed knob must never
941    /// silence logs).
942    pub fn from_env() -> Self {
943        let sample = std::env::var("DWARA_ACCESS_LOG_SAMPLE")
944            .ok()
945            .and_then(|v| v.parse::<f64>().ok())
946            .filter(|f| (0.0..=1.0).contains(f))
947            .unwrap_or(1.0);
948        let obs = Self::new();
949        obs.set_access_sample(sample);
950        obs
951    }
952
953    pub fn new() -> Self {
954        let registry = Registry::new();
955        let requests_total = IntCounterVec::new(
956            Opts::new(
957                "requests_total",
958                "Requests handled by the gateway, by route/listener/status class.",
959            ),
960            &["route", "listener", "status_class"],
961        )
962        .expect("valid metric definition");
963        let request_duration = HistogramVec::new(
964            HistogramOpts::new(
965                "request_duration_seconds",
966                "Gateway request latency (route resolution to response headers).",
967            )
968            .buckets(DURATION_BUCKETS.to_vec()),
969            &["route"],
970        )
971        .expect("valid metric definition");
972        let upstream_attempts_total = IntCounterVec::new(
973            Opts::new(
974                "upstream_attempts_total",
975                "Upstream send attempts, by upstream/endpoint/status class.",
976            ),
977            &["upstream", "endpoint", "status_class"],
978        )
979        .expect("valid metric definition");
980        let retries_total = IntCounterVec::new(
981            Opts::new("retries_total", "Retried upstream attempts, by upstream."),
982            &["upstream"],
983        )
984        .expect("valid metric definition");
985        let hedge_sent_total = IntCounterVec::new(
986            Opts::new(
987                "dwara_hedge_sent_total",
988                "Speculative hedge copies sent (DW-063), by upstream. Counts \
989                 every duplicate the hedge timer fired, regardless of whether \
990                 it won the race.",
991            ),
992            &["upstream"],
993        )
994        .expect("valid metric definition");
995        let mirror_sent_total = IntCounterVec::new(
996            Opts::new(
997                "dwara_mirror_sent_total",
998                "Shadow traffic mirror requests sent (DW-062), by upstream. \
999                 Counts every fire-and-forget mirror copy dispatched to a \
1000                 mirror upstream.",
1001            ),
1002            &["upstream"],
1003        )
1004        .expect("valid metric definition");
1005        let rate_limited_total = IntCounterVec::new(
1006            Opts::new(
1007                "rate_limited_total",
1008                "Requests denied by rate limiting, by route.",
1009            ),
1010            &["route"],
1011        )
1012        .expect("valid metric definition");
1013        let shed_total = IntCounterVec::new(
1014            Opts::new(
1015                "shed_total",
1016                "Requests shed by the gateway concurrency cap, by priority class.",
1017            ),
1018            &["priority"],
1019        )
1020        .expect("valid metric definition");
1021        let policy_dry_run_total = IntCounterVec::new(
1022            Opts::new(
1023                "dwara_policy_dry_run_total",
1024                "Requests a dry-run (monitor) policy would have rejected, by phase and \
1025                 route; the request was allowed (DW-041). Phases: route_limits, authz, \
1026                 rate_limit, load_shed.",
1027            ),
1028            &["phase", "route"],
1029        )
1030        .expect("valid metric definition");
1031        let breaker_state = IntGaugeVec::new(
1032            Opts::new(
1033                "breaker_state",
1034                "Upstream circuit breaker state: 0 closed, 1 open, 2 half-open.",
1035            ),
1036            &["upstream"],
1037        )
1038        .expect("valid metric definition");
1039        let endpoint_health = IntGaugeVec::new(
1040            Opts::new(
1041                "endpoint_health",
1042                "Endpoint availability under passive/active health: 1 available, 0 ejected.",
1043            ),
1044            &["upstream", "endpoint"],
1045        )
1046        .expect("valid metric definition");
1047        let fail_open_picks = IntGaugeVec::new(
1048            Opts::new(
1049                "upstream_fail_open_picks",
1050                "Balancer picks that fail-opened over an all-ejected pool (scrape-time snapshot).",
1051            ),
1052            &["upstream"],
1053        )
1054        .expect("valid metric definition");
1055        let jwks_refresh_total = IntCounterVec::new(
1056            Opts::new(
1057                "jwks_refresh_total",
1058                "JWKS fetch attempts (stale and rotation-triggered), by provider.",
1059            ),
1060            &["provider"],
1061        )
1062        .expect("valid metric definition");
1063        let active_requests = IntGauge::new(
1064            "active_requests",
1065            "Requests currently between handle() entry and response-header completion.",
1066        )
1067        .expect("valid metric definition");
1068        let config_generation = IntGauge::new(
1069            "config_generation",
1070            "Currently published configuration generation number.",
1071        )
1072        .expect("valid metric definition");
1073        let rate_limiter_evictions = IntGauge::new(
1074            "dwara_rate_limiter_evictions_total",
1075            "Rate-limiter per-key cells dropped by eviction sweeps, aggregated over \
1076             every compiled rule (scrape-time snapshot of the engine's monotonic \
1077             counter; resets when a reload rebuilds the engine).",
1078        )
1079        .expect("valid metric definition");
1080        let rate_limiter_live_keys = IntGauge::new(
1081            "dwara_rate_limiter_live_keys",
1082            "Live per-key rate-limiter cells across every compiled rule \
1083             (approximate under concurrent checks; bounded by the sharded \
1084             store cap per window).",
1085        )
1086        .expect("valid metric definition");
1087        let webhook_events_total = IntCounterVec::new(
1088            Opts::new(
1089                "dwara_webhook_events_total",
1090                "Webhook deliveries by event kind and outcome (DW-044). \
1091                 outcome: delivered (2xx on some attempt), failed (retries \
1092                 exhausted, non-transient answer, or budget spent), dropped \
1093                 (never tried: envelope over the byte cap or delivery \
1094                 concurrency saturated).",
1095            ),
1096            &["kind", "outcome"],
1097        )
1098        .expect("valid metric definition");
1099        let events_dropped = IntGauge::new(
1100            "dwara_events_dropped_total",
1101            "Events dropped at EMIT time (event queue full or no deliverer \
1102             draining); scrape-time snapshot of the event bus's monotonic \
1103             counter (DW-044).",
1104        )
1105        .expect("valid metric definition");
1106        let events_emitted = IntGauge::new(
1107            "dwara_events_emitted_total",
1108            "Events handed to the event queue since process start; \
1109             scrape-time snapshot of the event bus's monotonic counter \
1110             (DW-044).",
1111        )
1112        .expect("valid metric definition");
1113        let access_records_streamed_total = IntCounterVec::new(
1114            Opts::new(
1115                "dwara_access_records_streamed_total",
1116                "Access records counted per flushed-batch delivery outcome \
1117                 (DW-121). outcome: delivered (the batch's sink accepted it, \
1118                 2xx on some attempt), failed (the batch was tried and not \
1119                 taken: retries exhausted, non-transient answer, or budget \
1120                 spent), dropped (never delivered: over the per-record byte \
1121                 cap, or flushed after the stream was disabled with a \
1122                 queued tail). Emit-time channel drops are the separate \
1123                 dwara_access_records_dropped_total gauge.",
1124            ),
1125            &["outcome"],
1126        )
1127        .expect("valid metric definition");
1128        let access_records_offered = IntGauge::new(
1129            "dwara_access_records_offered_total",
1130            "Access records offered to the stream channel at request \
1131             completion since process start; scrape-time snapshot of the \
1132             stream's monotonic counter (DW-121).",
1133        )
1134        .expect("valid metric definition");
1135        let access_records_dropped = IntGauge::new(
1136            "dwara_access_records_dropped_total",
1137            "Access records dropped at OFFER time (stream channel full, or \
1138             the stream disabled by the current generation); scrape-time \
1139             snapshot of the stream's monotonic counter (DW-121) — the \
1140             never-block posture's honest loss counter.",
1141        )
1142        .expect("valid metric definition");
1143        let access_records_sampled_dropped = IntGauge::new(
1144            "dwara_access_records_sampled_dropped_total",
1145            "Access records dropped by sampled degradation (REL-10, #221): \
1146             records probabilistically dropped when the analytics channel \
1147             fill ratio exceeded the high watermark, before the channel \
1148             was full. Scrape-time snapshot of the analytics store's \
1149             monotonic counter — distinct from \
1150             dwara_access_records_dropped_total (channel was full).",
1151        )
1152        .expect("valid metric definition");
1153        let split_picks_total = IntCounterVec::new(
1154            Opts::new(
1155                "dwara_split_picks_total",
1156                "Service-split dispatch decisions (DW-040): one per request \
1157                 dispatched through a weighted split, by service and the \
1158                 target upstream chosen. The canary share is the upstream's \
1159                 share of the service's total.",
1160            ),
1161            &["service", "upstream"],
1162        )
1163        .expect("valid metric definition");
1164        let sticky_sessions_total = IntCounter::new(
1165            "dwara_sticky_sessions_total",
1166            "Sticky sessions minted (DW-040): affinity cookies set on a first \
1167             response.",
1168        )
1169        .expect("valid metric definition");
1170        let websocket_policy_total = IntCounterVec::new(
1171            Opts::new(
1172                "dwara_websocket_policy_total",
1173                "WebSocket policy decisions (DW-039), by route. outcome: \
1174                 origin_denied (an upgrade handshake rejected by the route's \
1175                 origin allowlist, before any upstream contact), rate_closed \
1176                 (an established tunnel closed by the frame-rate policer, \
1177                 close code 1008; counted when the tunnel task completes, so \
1178                 a process exit mid-tunnel can undercount).",
1179            ),
1180            &["route", "outcome"],
1181        )
1182        .expect("valid metric definition");
1183        let cache_lookups_total = IntCounterVec::new(
1184            Opts::new(
1185                "dwara_cache_lookups_total",
1186                "Response-cache lookups by outcome (DW-037), one per request on a \
1187                 cache-configured route. outcome: hit (fresh entry served), stale \
1188                 (expired entry served inside the stale-while-revalidate window), \
1189                 miss (no usable entry; the request went upstream), bypass (the \
1190                 request shape is not cacheable).",
1191            ),
1192            &["outcome"],
1193        )
1194        .expect("valid metric definition");
1195        let cache_stores_total = IntCounterVec::new(
1196            Opts::new(
1197                "dwara_cache_stores_total",
1198                "Response-cache store attempts by outcome (DW-037). outcome: \
1199                 stored, vetoed (a response-side rule forbade storage), over_cap \
1200                 (body larger than the route's max_body_bytes; passed through \
1201                 unbuffered), error (the backing store failed).",
1202            ),
1203            &["outcome"],
1204        )
1205        .expect("valid metric definition");
1206        let cache_revalidated_total = IntCounter::new(
1207            "dwara_cache_revalidated_total",
1208            "Responses served from the stored body after the upstream confirmed \
1209             the validator with 304 (DW-037).",
1210        )
1211        .expect("valid metric definition");
1212        let cache_purges_total = IntCounterVec::new(
1213            Opts::new(
1214                "dwara_cache_purges_total",
1215                "Cache purge operations by scope (DW-037, DP-04): route \
1216                 (one named route), all (every route), tag (Cache-Tags \
1217                 purge), url (exact URL purge), or url_prefix (prefix \
1218                 URL purge).",
1219            ),
1220            &["scope"],
1221        )
1222        .expect("valid metric definition");
1223        let cache_entries = IntGauge::new(
1224            "dwara_cache_entries",
1225            "Approximate live response-cache entries (DW-037); scrape-time \
1226             snapshot of the backing store's entry count.",
1227        )
1228        .expect("valid metric definition");
1229        let coalescing_leaders_total = IntCounter::new(
1230            "dwara_coalescing_leaders_total",
1231            "Cache misses that became the request-coalescing leader (DW-038): \
1232             their upstream fetch ran while later identical misses waited.",
1233        )
1234        .expect("valid metric definition");
1235        let coalescing_followers_total = IntCounterVec::new(
1236            Opts::new(
1237                "dwara_coalescing_followers_total",
1238                "Request-coalescing follower resolutions by outcome (DW-038). \
1239                 outcome: served (the leader's stored outcome replayed, one \
1240                 upstream call saved), fell_back_timeout (the wait bound \
1241                 expired), fell_back_unshared (the leader finished without a \
1242                 storable outcome; the follower fetched on its own), \
1243                 fell_back_epoch (a purge/config change invalidated the \
1244                 generation mid-flight; the follower fetched on its own).",
1245            ),
1246            &["outcome"],
1247        )
1248        .expect("valid metric definition");
1249        let coalescing_saved_upstream_calls_total = IntCounter::new(
1250            "dwara_coalescing_saved_upstream_calls_total",
1251            "Upstream calls avoided by request coalescing (DW-038); one per \
1252             served follower.",
1253        )
1254        .expect("valid metric definition");
1255        let coalescing_waiters = IntGauge::new(
1256            "dwara_coalescing_waiters",
1257            "Requests currently parked as request-coalescing followers \
1258             (DW-038).",
1259        )
1260        .expect("valid metric definition");
1261        let quota_denied_total = IntCounterVec::new(
1262            Opts::new(
1263                "dwara_quota_denied_total",
1264                "Requests refused by a consumer request budget (429), by consumer \
1265                 and budget (DW-033). budget is the binding wall: daily or monthly.",
1266            ),
1267            &["consumer", "budget"],
1268        )
1269        .expect("valid metric definition");
1270        let quota_used = IntGaugeVec::new(
1271            Opts::new(
1272                "dwara_quota_used",
1273                "Requests counted in a consumer budget's current UTC window \
1274                 (scrape-time snapshot of the state store's quota counters; \
1275                 DW-033).",
1276            ),
1277            &["consumer", "budget"],
1278        )
1279        .expect("valid metric definition");
1280        let quota_limit = IntGaugeVec::new(
1281            Opts::new(
1282                "dwara_quota_limit",
1283                "A consumer budget's configured cap for the current window \
1284                 (scrape-time; DW-033) — pairs with dwara_quota_used for \
1285                 used/limit alerting without a config join.",
1286            ),
1287            &["consumer", "budget"],
1288        )
1289        .expect("valid metric definition");
1290        // DW-052: created once, shared between the recorder methods and
1291        // the registered collector handle (the window state must be the
1292        // SAME object the completion path writes and the scrape reads).
1293        let slo = std::sync::Arc::new(SloState::new());
1294        let admission_queued_total = IntCounterVec::new(
1295            Opts::new(
1296                "dwara_admission_queued_total",
1297                "Admission queue outcomes (DW-053). outcome: admitted (a \
1298                 permit was acquired after queueing), timeout (the queue \
1299                 timeout expired before a permit was available; the request \
1300                 was shed with 503), queue_full (the queue was at capacity \
1301                 and the request was shed immediately with 503).",
1302            ),
1303            &["outcome"],
1304        )
1305        .expect("valid metric definition");
1306        let admission_queue_depth = IntGauge::new(
1307            "dwara_admission_queue_depth",
1308            "Current number of requests waiting in the admission queue for a \
1309             concurrency permit (DW-053).",
1310        )
1311        .expect("valid metric definition");
1312        let waf_total = IntCounterVec::new(
1313            Opts::new(
1314                "dwara_waf_total",
1315                "WAF-lite inspection outcomes (DW-051), by route and filter. \
1316                 outcome: blocked (a match was found and the request was \
1317                 rejected with 403), logged (a dry-run match; the request \
1318                 was allowed), passed (no match; counted once per inspected \
1319                 request with filter label 'all').",
1320            ),
1321            &["route", "filter", "outcome"],
1322        )
1323        .expect("valid metric definition");
1324        let anomaly_total = IntCounterVec::new(
1325            Opts::new(
1326                "dwara_anomaly_total",
1327                "Anomaly-scoring inspection outcomes (DW-090), by route. \
1328                 outcome: blocked (the score was at or above the threshold \
1329                 and the request was rejected with 403), logged (a dry-run \
1330                 match; the request was allowed), passed (the score was \
1331                 below the threshold; counted once per inspected request).",
1332            ),
1333            &["route", "outcome"],
1334        )
1335        .expect("valid metric definition");
1336        let graphql_requests_total = IntCounterVec::new(
1337            Opts::new(
1338                "dwara_graphql_requests_total",
1339                "GraphQL check outcomes (DW-099), by route and outcome. \
1340                 outcome: allowed (the query passed all checks), \
1341                 depth_exceeded (the query depth exceeded the configured \
1342                 limit), complexity_exceeded (the query complexity exceeded \
1343                 the configured limit), persisted_query_required (the query \
1344                 hash was not in the persisted-query store).",
1345            ),
1346            &["route", "outcome"],
1347        )
1348        .expect("valid metric definition");
1349        let graphql_depth = IntGaugeVec::new(
1350            Opts::new(
1351                "dwara_graphql_depth",
1352                "Last GraphQL query depth (DW-099), by route. The maximum \
1353                 brace-nesting level of the most recently checked query.",
1354            ),
1355            &["route"],
1356        )
1357        .expect("valid metric definition");
1358        let graphql_complexity = IntGaugeVec::new(
1359            Opts::new(
1360                "dwara_graphql_complexity",
1361                "Last GraphQL query complexity (DW-099), by route. The sum \
1362                 of per-field costs of the most recently checked query.",
1363            ),
1364            &["route"],
1365        )
1366        .expect("valid metric definition");
1367        let license_status = IntGauge::new(
1368            "dwara_license_status",
1369            "License status (DW-032): 0 = no license (OSS mode), 1 = valid, \
1370             2 = expired within grace period, 3 = expired past grace period \
1371             (degraded to OSS).",
1372        )
1373        .expect("valid metric definition");
1374        let dns_discovery_endpoints = IntGaugeVec::new(
1375            Opts::new(
1376                "dwara_dns_discovery_endpoints",
1377                "Current resolved endpoint count for DNS-discovered upstreams \
1378                 (DW-042). The upstream label is config-bounded; cardinality is \
1379                 never per resolved address.",
1380            ),
1381            &["upstream"],
1382        )
1383        .expect("valid metric definition");
1384        let dns_discovery_refresh_total = IntCounterVec::new(
1385            Opts::new(
1386                "dwara_dns_discovery_refresh_total",
1387                "DNS discovery refresh attempts (DW-042), by upstream.",
1388            ),
1389            &["upstream"],
1390        )
1391        .expect("valid metric definition");
1392        let dns_discovery_refresh_failures_total = IntCounterVec::new(
1393            Opts::new(
1394                "dwara_dns_discovery_refresh_failures_total",
1395                "DNS discovery refresh failures (DW-042), by upstream.",
1396            ),
1397            &["upstream"],
1398        )
1399        .expect("valid metric definition");
1400        let config_convergence_generation = IntGaugeVec::new(
1401            Opts::new(
1402                "dwara_config_convergence_generation",
1403                "This instance's current config convergence generation \
1404                 (DW-054), by instance id. The instance label is \
1405                 config-bounded (one series per process).",
1406            ),
1407            &["instance"],
1408        )
1409        .expect("valid metric definition");
1410        let config_convergence_instances = IntGauge::new(
1411            "dwara_config_convergence_instances",
1412            "Total gateway instances the convergence backend currently \
1413             reports (DW-054); the cluster view the drift check reads.",
1414        )
1415        .expect("valid metric definition");
1416        let config_convergence_drift = IntGauge::new(
1417            "dwara_config_convergence_drift",
1418            "Config convergence drift flag (DW-054): 1 when one or more \
1419             instances serve a different config hash than the majority, 0 \
1420             when the cluster is converged.",
1421        )
1422        .expect("valid metric definition");
1423        let config_convergence_refresh_total = IntCounter::new(
1424            "dwara_config_convergence_refresh_total",
1425            "Config convergence refresh attempts (DW-054): polls of the \
1426             convergence backend for remote generations.",
1427        )
1428        .expect("valid metric definition");
1429        let config_convergence_refresh_failures_total = IntCounter::new(
1430            "dwara_config_convergence_refresh_failures_total",
1431            "Config convergence refresh failures (DW-054): backend \
1432             unreachable or returned malformed data.",
1433        )
1434        .expect("valid metric definition");
1435        let ai_requests_total = IntCounterVec::new(
1436            Opts::new(
1437                "dwara_ai_requests_total",
1438                "AI route requests (DW-075), by provider/route/outcome. Outcomes: \
1439                 success, provider_error, transport_error, translation_error. \
1440                 The version label is the canary version that served (DW-076) \
1441                 or default. Client-side rejections never reach a provider and \
1442                 are not counted here.",
1443            ),
1444            &["provider", "route", "outcome", "version"],
1445        )
1446        .expect("valid metric definition");
1447        let ai_budget_denied_total = IntCounterVec::new(
1448            Opts::new(
1449                "dwara_ai_budget_denied_total",
1450                "AI token/cost budget denials (DW-078), by kind (tokens | \
1451                 cost). Consumer identity is in the access log, not a \
1452                 label.",
1453            ),
1454            &["kind"],
1455        )
1456        .expect("valid metric definition");
1457        let ai_governance_denied_total = IntCounterVec::new(
1458            Opts::new(
1459                "dwara_ai_governance_denied_total",
1460                "AI model governance denials (DW-084), by reason. The reason \
1461                 is a config-bounded label (the denial cause string), not the \
1462                 consumer or model — cardinality rule. Consumer and model are \
1463                 in the governance audit table and the access log.",
1464            ),
1465            &["reason"],
1466        )
1467        .expect("valid metric definition");
1468        let ai_guardrail_blocked_total = IntCounterVec::new(
1469            Opts::new(
1470                "dwara_ai_guardrail_blocked_total",
1471                "AI guardrail blocks (DW-082), by rule name and kind. The rule \
1472                 name is a config-bounded label (the rule's declared name), and \
1473                 the kind labels the phase/kind (prompt, response, schema, \
1474                 pii, injection, banned). No consumer label — cardinality rule.",
1475            ),
1476            &["rule", "kind"],
1477        )
1478        .expect("valid metric definition");
1479        let ai_semantic_cache_hits_total = IntCounterVec::new(
1480            Opts::new(
1481                "dwara_ai_semantic_cache_hits_total",
1482                "AI semantic cache hits (DW-083), by model alias. The model \
1483                 is the client-facing alias (config-bounded label). No consumer \
1484                 label — cardinality rule.",
1485            ),
1486            &["model"],
1487        )
1488        .expect("valid metric definition");
1489        let ai_semantic_cache_misses_total = IntCounterVec::new(
1490            Opts::new(
1491                "dwara_ai_semantic_cache_misses_total",
1492                "AI semantic cache misses (DW-083), by model alias. The model \
1493                 is the client-facing alias (config-bounded label). No consumer \
1494                 label — cardinality rule.",
1495            ),
1496            &["model"],
1497        )
1498        .expect("valid metric definition");
1499        let ai_routing_policy_escalations_total = IntCounterVec::new(
1500            Opts::new(
1501                "dwara_ai_routing_policy_escalations_total",
1502                "AI routing-policy FallbackChain escalations (DW-085): complex \
1503                 prompts (classifier score >= threshold) routed to the costlier \
1504                 model, by policy name (config-bounded label). No consumer \
1505                 label — cardinality rule.",
1506            ),
1507            &["policy"],
1508        )
1509        .expect("valid metric definition");
1510        let ai_routing_policy_cheap_total = IntCounterVec::new(
1511            Opts::new(
1512                "dwara_ai_routing_policy_cheap_total",
1513                "AI routing-policy FallbackChain cheap-model selections \
1514                 (DW-085): simple prompts (classifier score < threshold) routed \
1515                 to the cheap model, by policy name (config-bounded label). No \
1516                 consumer label — cardinality rule.",
1517            ),
1518            &["policy"],
1519        )
1520        .expect("valid metric definition");
1521        let ai_routing_policy_latency_cost_selections_total = IntCounterVec::new(
1522            Opts::new(
1523                "dwara_ai_routing_policy_latency_cost_selections_total",
1524                "AI routing-policy LatencyCost selections (DW-085): requests \
1525                 routed by the static cost/latency policy, by policy name \
1526                 (config-bounded label). No consumer label — cardinality rule.",
1527            ),
1528            &["policy"],
1529        )
1530        .expect("valid metric definition");
1531        let ai_experiment_variant_selections_total = IntCounterVec::new(
1532            Opts::new(
1533                "dwara_ai_experiment_variant_selections_total",
1534                "AI experiment (A/B test) variant selections (DW-086), by \
1535                 experiment and variant (config-bounded labels). No consumer \
1536                 label — cardinality rule; the consumer is in the access log \
1537                 and the analytics assignment table.",
1538            ),
1539            &["experiment", "variant"],
1540        )
1541        .expect("valid metric definition");
1542        let ai_stream_chunks_total = IntCounterVec::new(
1543            Opts::new(
1544                "dwara_ai_stream_chunks_total",
1545                "Forwarded AI streaming chunks (DW-077), by provider. One per \
1546                 client-visible delta chunk.",
1547            ),
1548            &["provider"],
1549        )
1550        .expect("valid metric definition");
1551        let ai_first_token_seconds = HistogramVec::new(
1552            HistogramOpts::new(
1553                "dwara_ai_first_token_seconds",
1554                "Time to the first forwarded chunk of an AI stream (DW-077), \
1555                 by provider.",
1556            )
1557            .buckets(FIRST_TOKEN_BUCKETS.to_vec()),
1558            &["provider"],
1559        )
1560        .expect("valid metric definition");
1561        let ai_stream_duration_seconds = HistogramVec::new(
1562            HistogramOpts::new(
1563                "dwara_ai_stream_duration_seconds",
1564                "Total AI stream duration (DW-077) — request send to stream \
1565                 end — by provider.",
1566            )
1567            .buckets(DURATION_BUCKETS.to_vec()),
1568            &["provider"],
1569        )
1570        .expect("valid metric definition");
1571        let ai_request_duration_seconds = HistogramVec::new(
1572            HistogramOpts::new(
1573                "dwara_ai_request_duration_seconds",
1574                "AI request latency (AI-07) — request parse through response \
1575                 translation — by provider (config-bounded) and route \
1576                 (config-bounded), in seconds. Observed once per completed AI \
1577                 request (success and provider/translation errors alike).",
1578            )
1579            .buckets(DURATION_BUCKETS.to_vec()),
1580            &["provider", "route"],
1581        )
1582        .expect("valid metric definition");
1583        let ai_tokens_per_request = HistogramVec::new(
1584            HistogramOpts::new(
1585                "dwara_ai_tokens_per_request",
1586                "Distribution of provider-reported tokens per AI request \
1587                 (AI-07), by provider (config-bounded), provider_model \
1588                 (config-bounded), and kind (prompt | completion). \
1589                 Provider-reported only; the gateway never estimates. No \
1590                 consumer label (cardinality rule).",
1591            )
1592            .buckets(TOKENS_PER_REQUEST_BUCKETS.to_vec()),
1593            &["provider", "model", "kind"],
1594        )
1595        .expect("valid metric definition");
1596        let mcp_sessions_total = IntCounterVec::new(
1597            Opts::new(
1598                "dwara_mcp_sessions_total",
1599                "MCP session lifecycle transitions (DW-087), by state. \
1600                 state: initialized (a new session created by initialize), \
1601                 closed (a session deleted by shutdown), expired (a session \
1602                 reaped by the TTL cleanup).",
1603            ),
1604            &["state"],
1605        )
1606        .expect("valid metric definition");
1607        let mcp_tool_calls_total = IntCounterVec::new(
1608            Opts::new(
1609                "dwara_mcp_tool_calls_total",
1610                "MCP tool calls (DW-087), by tool and status. tool is the \
1611                 config-declared tool name (config-bounded); status is the \
1612                 closed set success, error, denied. No consumer label \
1613                 (cardinality rule); the consumer is in the access log and \
1614                 the analytics mcp_tool_calls table.",
1615            ),
1616            &["tool", "status"],
1617        )
1618        .expect("valid metric definition");
1619        let mcp_tool_duration_seconds = HistogramVec::new(
1620            HistogramOpts::new(
1621                "dwara_mcp_tool_duration_seconds",
1622                "MCP tool call duration (DW-087) -- authz check through \
1623                 upstream response -- by tool (config-bounded label).",
1624            )
1625            .buckets(DURATION_BUCKETS.to_vec()),
1626            &["tool"],
1627        )
1628        .expect("valid metric definition");
1629        let a2a_requests_total = IntCounterVec::new(
1630            Opts::new(
1631                "dwara_a2a_requests_total",
1632                "A2A (agent-to-agent) requests (DW-114), by agent and \
1633                 outcome. agent is the config-declared agent name \
1634                 (config-bounded); outcome is the closed set success, \
1635                 error, stubbed (stubbed = the task lifecycle is not \
1636                 yet implemented pending spec freeze). No consumer label \
1637                 (cardinality rule); the consumer is in the access log.",
1638            ),
1639            &["agent", "outcome"],
1640        )
1641        .expect("valid metric definition");
1642        let a2a_sessions_total = IntCounterVec::new(
1643            Opts::new(
1644                "dwara_a2a_sessions_total",
1645                "A2A session lifecycle transitions (DW-114), by state. \
1646                 state: initialized (a new task session created), closed \
1647                 (a session deleted), expired (a session reaped by the \
1648                 TTL cleanup). A closed three-value label set, so \
1649                 cardinality is three series total (mirrors MCP).",
1650            ),
1651            &["state"],
1652        )
1653        .expect("valid metric definition");
1654        let nano_service_requests_total = IntCounterVec::new(
1655            Opts::new(
1656                "dwara_nano_service_requests_total",
1657                "Nano-service (WASM route handler) request outcomes (DW-106), \
1658                 by route and outcome. route is the config-declared route \
1659                 name (config-bounded); outcome is the closed set success, \
1660                 error, timeout. No consumer label (cardinality rule); the \
1661                 consumer is in the access log.",
1662            ),
1663            &["route", "outcome"],
1664        )
1665        .expect("valid metric definition");
1666        let nano_service_duration_seconds = HistogramVec::new(
1667            HistogramOpts::new(
1668                "dwara_nano_service_duration_seconds",
1669                "Nano-service handle call duration (DW-106) -- the WASM \
1670                 module's request->response execution -- by route \
1671                 (config-bounded label).",
1672            )
1673            .buckets(DURATION_BUCKETS.to_vec()),
1674            &["route"],
1675        )
1676        .expect("valid metric definition");
1677        let plugin_failures_total = IntCounterVec::new(
1678            Opts::new(
1679                "dwara_plugin_failures_total",
1680                "Plugin dispatch failures (DW-157), by plugin name and \
1681                 reason. name is the config-declared plugin name \
1682                 (config-bounded); reason is the closed set crashed, \
1683                 disabled, not_loaded, not_registered, instantiate_failed, \
1684                 trap, body_too_large, response_stream_ended, \
1685                 callout_failed, callout_loop. Counted once \
1686                 per request the plugin \
1687                 chain answered with an error (a send_http_response \
1688                 short-circuit is a plugin decision, not a failure).",
1689            ),
1690            &["name", "reason"],
1691        )
1692        .expect("valid metric definition");
1693        let plugin_callouts_total = IntCounterVec::new(
1694            Opts::new(
1695                "dwara_plugin_callouts_total",
1696                "Plugin HTTP callout outcomes (DW-167), by plugin name \
1697                 and outcome. name is the config-declared plugin name \
1698                 (config-bounded); outcome is the closed set ok (a \
1699                 completed response was delivered, any status), timeout \
1700                 (the clamped whole-exchange deadline expired), error \
1701                 (connect/DNS/TLS/SSRF/framing/cap failures), loop_guard \
1702                 (the per-phase round cap stopped a runaway dispatch \
1703                 loop). Counted once per performed callout (loop_guard \
1704                 once per guard trip).",
1705            ),
1706            &["name", "outcome"],
1707        )
1708        .expect("valid metric definition");
1709        let plugin_total = IntGaugeVec::new(
1710            Opts::new(
1711                "dwara_plugin_total",
1712                "Plugins per lifecycle state (DW-158), refreshed at \
1713                 publish time. state: healthy, crashed (load or compile \
1714                 failed; routes referencing it answer 500), disabled, \
1715                 not_loaded (declared but absent from the runtime -- an \
1716                 unresolved registry source or a failed engine rebuild), \
1717                 not_registered (native filter name with no registered \
1718                 factory). A closed five-value label set over the \
1719                 generation's declared plugins -- the same enumeration \
1720                 GET /plugins serves.",
1721            ),
1722            &["state"],
1723        )
1724        .expect("valid metric definition");
1725        let tls_pq_handshakes_total = IntCounterVec::new(
1726            Opts::new(
1727                "dwara_tls_pq_handshakes_total",
1728                "Post-quantum TLS handshake outcomes (DW-105), by result. \
1729                 result: success (the X25519+ML-KEM hybrid kx group was \
1730                 negotiated), fallback (the client did not support the \
1731                 hybrid group and a classical group was negotiated), \
1732                 disabled (PQ was off or the experimental API was inert). \
1733                 A closed three-value label set, so cardinality is three \
1734                 series total.",
1735            ),
1736            &["result"],
1737        )
1738        .expect("valid metric definition");
1739        let rate_limiter_adaptive_factor = GaugeVec::new(
1740            Opts::new(
1741                "dwara_rate_limiter_adaptive_factor",
1742                "Current adaptive rate-limit factor (DW-089), by policy \
1743                 (config-bounded). 1.0 = configured rate; < 1.0 tightens, \
1744                 > 1.0 relaxes. Bounded [min_factor, max_factor].",
1745            ),
1746            &["policy"],
1747        )
1748        .expect("valid metric definition");
1749        let rate_limiter_origin_signal_total = IntCounterVec::new(
1750            Opts::new(
1751                "dwara_rate_limiter_origin_signal_total",
1752                "Upstream origin signals received by the adaptive controller \
1753                 (DW-089), by policy and signal (both config-bounded; signal \
1754                 is the closed set retry_after).",
1755            ),
1756            &["policy", "signal"],
1757        )
1758        .expect("valid metric definition");
1759        let rate_limiter_adaptive_tightened_total = IntCounterVec::new(
1760            Opts::new(
1761                "dwara_rate_limiter_adaptive_tightened_total",
1762                "Adaptive rate-limit factor decreased / tightened (DW-089), by \
1763                 policy (config-bounded). Counted once per stressed update that \
1764                 moved the factor down.",
1765            ),
1766            &["policy"],
1767        )
1768        .expect("valid metric definition");
1769        let rate_limiter_adaptive_relaxed_total = IntCounterVec::new(
1770            Opts::new(
1771                "dwara_rate_limiter_adaptive_relaxed_total",
1772                "Adaptive rate-limit factor increased / relaxed (DW-089), by \
1773                 policy (config-bounded). Counted once per healthy update that \
1774                 moved the factor up.",
1775            ),
1776            &["policy"],
1777        )
1778        .expect("valid metric definition");
1779        let canary_promotions_total = IntCounterVec::new(
1780            Opts::new(
1781                "dwara_canary_promotions_total",
1782                "Canary promotions (DW-091), by group (config-bounded: service \
1783                 name or AI alias). Counted once per promote action the \
1784                 auto-canary controller applied.",
1785            ),
1786            &["group"],
1787        )
1788        .expect("valid metric definition");
1789        let canary_rollbacks_total = IntCounterVec::new(
1790            Opts::new(
1791                "dwara_canary_rollbacks_total",
1792                "Canary rollbacks (DW-091), by group (config-bounded: service \
1793                 name or AI alias). Counted once per rollback action (including \
1794                 severe-regression immediate rollbacks to 0).",
1795            ),
1796            &["group"],
1797        )
1798        .expect("valid metric definition");
1799        let canary_weight = GaugeVec::new(
1800            Opts::new(
1801                "dwara_canary_weight",
1802                "Current canary weight (DW-091), by group (config-bounded: \
1803                 service name or AI alias). Set on every applied adjustment. \
1804                 The total weight stays constant; the baseline absorbs the \
1805                 difference.",
1806            ),
1807            &["group"],
1808        )
1809        .expect("valid metric definition");
1810        let mesh_connections_total = IntCounterVec::new(
1811            Opts::new(
1812                "dwara_mesh_connections_total",
1813                "Sidecar connections proxied (DW-107), by mode and direction. \
1814                 `mode` is the closed set `sidecar`; `direction` is the closed \
1815                 set `inbound`, `outbound`. Inbound: terminate mTLS, apply \
1816                 policies, forward to the local app. Outbound: apply policies, \
1817                 wrap in mTLS, dial the remote sidecar.",
1818            ),
1819            &["mode", "direction"],
1820        )
1821        .expect("valid metric definition");
1822        let spiffe_svid_refresh_total = IntCounterVec::new(
1823            Opts::new(
1824                "dwara_spiffe_svid_refresh_total",
1825                "SPIFFE SVID refresh attempts (DW-107), by result. `result` is \
1826                 the closed set `success`, `error`. Counted once per Workload \
1827                 API refresh attempt; an error keeps serving the previous SVID \
1828                 until it expires.",
1829            ),
1830            &["result"],
1831        )
1832        .expect("valid metric definition");
1833        let spiffe_svid_expiry_seconds = IntGauge::new(
1834            "dwara_spiffe_svid_expiry_seconds",
1835            "Seconds until the current SPIFFE SVID expires (DW-107). Set on \
1836             every successful refresh; counts down to 0 as the SVID nears \
1837             expiry. The refresh loop schedules the next fetch at half the \
1838             remaining lifetime by default.",
1839        )
1840        .expect("valid metric definition");
1841        let l4_connections_total = IntCounterVec::new(
1842            Opts::new(
1843                "dwara_l4_connections_total",
1844                "L4 connections proxied (DW-103), by listener and protocol. \
1845                 listener is the config-declared listener name (config-bounded); \
1846                 protocol is the closed set tcp, udp. Counted once per accepted \
1847                 L4 connection (before the splice is established).",
1848            ),
1849            &["listener", "protocol"],
1850        )
1851        .expect("valid metric definition");
1852        let l4_connection_duration_seconds = HistogramVec::new(
1853            HistogramOpts::new(
1854                "dwara_l4_connection_duration_seconds",
1855                "L4 connection duration (DW-103) -- splice establishment through \
1856                 close -- by listener (config-bounded label), in seconds. Observed \
1857                 once per connection (forwarded and closed alike).",
1858            )
1859            .buckets(DURATION_BUCKETS.to_vec()),
1860            &["listener"],
1861        )
1862        .expect("valid metric definition");
1863        let ai_tokens_total = IntCounterVec::new(
1864            Opts::new(
1865                "dwara_ai_tokens_total",
1866                "Provider-reported AI token usage (DW-075), by provider and \
1867                 direction (prompt|completion), with the canary version label \
1868                 (DW-076) or default. Provider-reported only; the gateway never \
1869                 estimates.",
1870            ),
1871            &["provider", "kind", "version"],
1872        )
1873        .expect("valid metric definition");
1874        let ai_cost_micros_total = IntCounterVec::new(
1875            Opts::new(
1876                "dwara_ai_cost_micros_total",
1877                "Total priced AI spend in micro-USD (DW-079), by provider and \
1878                 provider_model. Priced through the dataplane's compiled pricing \
1879                 table; 0 for unknown models. No consumer label (cardinality).",
1880            ),
1881            &["provider", "model"],
1882        )
1883        .expect("valid metric definition");
1884        let ai_unknown_pricing_total = IntCounterVec::new(
1885            Opts::new(
1886                "dwara_ai_unknown_pricing_total",
1887                "Requests served by a model with no pricing entry (DW-AI-03), by \
1888                 provider_model. Incremented only under the alert unknown-model \
1889                 policy (allow is silent; fail_closed rejects before recording). \
1890                 The model label is the provider model identifier (config-bounded).",
1891            ),
1892            &["model"],
1893        )
1894        .expect("valid metric definition");
1895        // Clones share state (every prometheus family is a shared handle),
1896        // so registering clones keeps the originals usable for recording.
1897        for m in [
1898            Box::new(requests_total.clone()) as Box<dyn prometheus::core::Collector>,
1899            Box::new(request_duration.clone()),
1900            Box::new(upstream_attempts_total.clone()),
1901            Box::new(retries_total.clone()),
1902            Box::new(hedge_sent_total.clone()),
1903            Box::new(mirror_sent_total.clone()),
1904            Box::new(rate_limited_total.clone()),
1905            Box::new(shed_total.clone()),
1906            Box::new(policy_dry_run_total.clone()),
1907            Box::new(breaker_state.clone()),
1908            Box::new(endpoint_health.clone()),
1909            Box::new(fail_open_picks.clone()),
1910            Box::new(jwks_refresh_total.clone()),
1911            Box::new(active_requests.clone()),
1912            Box::new(config_generation.clone()),
1913            Box::new(rate_limiter_evictions.clone()),
1914            Box::new(rate_limiter_live_keys.clone()),
1915            Box::new(webhook_events_total.clone()),
1916            Box::new(events_dropped.clone()),
1917            Box::new(events_emitted.clone()),
1918            Box::new(access_records_streamed_total.clone()),
1919            Box::new(access_records_offered.clone()),
1920            Box::new(access_records_dropped.clone()),
1921            Box::new(access_records_sampled_dropped.clone()),
1922            Box::new(websocket_policy_total.clone()),
1923            Box::new(split_picks_total.clone()),
1924            Box::new(sticky_sessions_total.clone()),
1925            Box::new(cache_lookups_total.clone()),
1926            Box::new(cache_stores_total.clone()),
1927            Box::new(cache_revalidated_total.clone()),
1928            Box::new(cache_purges_total.clone()),
1929            Box::new(cache_entries.clone()),
1930            Box::new(coalescing_leaders_total.clone()),
1931            Box::new(coalescing_followers_total.clone()),
1932            Box::new(coalescing_saved_upstream_calls_total.clone()),
1933            Box::new(coalescing_waiters.clone()),
1934            Box::new(quota_denied_total.clone()),
1935            Box::new(quota_used.clone()),
1936            Box::new(quota_limit.clone()),
1937            Box::new(SloCollectorHandle(std::sync::Arc::clone(&slo))),
1938            Box::new(admission_queued_total.clone()),
1939            Box::new(admission_queue_depth.clone()),
1940            Box::new(waf_total.clone()),
1941            Box::new(anomaly_total.clone()),
1942            Box::new(graphql_requests_total.clone()),
1943            Box::new(graphql_depth.clone()),
1944            Box::new(graphql_complexity.clone()),
1945            Box::new(license_status.clone()),
1946            Box::new(dns_discovery_endpoints.clone()),
1947            Box::new(dns_discovery_refresh_total.clone()),
1948            Box::new(dns_discovery_refresh_failures_total.clone()),
1949            Box::new(config_convergence_generation.clone()),
1950            Box::new(config_convergence_instances.clone()),
1951            Box::new(config_convergence_drift.clone()),
1952            Box::new(config_convergence_refresh_total.clone()),
1953            Box::new(config_convergence_refresh_failures_total.clone()),
1954            Box::new(ai_requests_total.clone()),
1955            Box::new(ai_tokens_total.clone()),
1956            Box::new(ai_cost_micros_total.clone()),
1957            Box::new(ai_unknown_pricing_total.clone()),
1958            Box::new(ai_stream_chunks_total.clone()),
1959            Box::new(ai_budget_denied_total.clone()),
1960            Box::new(ai_governance_denied_total.clone()),
1961            Box::new(ai_guardrail_blocked_total.clone()),
1962            Box::new(ai_semantic_cache_hits_total.clone()),
1963            Box::new(ai_semantic_cache_misses_total.clone()),
1964            Box::new(ai_routing_policy_escalations_total.clone()),
1965            Box::new(ai_routing_policy_cheap_total.clone()),
1966            Box::new(ai_routing_policy_latency_cost_selections_total.clone()),
1967            Box::new(ai_experiment_variant_selections_total.clone()),
1968            Box::new(ai_first_token_seconds.clone()),
1969            Box::new(ai_stream_duration_seconds.clone()),
1970            Box::new(ai_request_duration_seconds.clone()),
1971            Box::new(ai_tokens_per_request.clone()),
1972            Box::new(mcp_sessions_total.clone()),
1973            Box::new(mcp_tool_calls_total.clone()),
1974            Box::new(mcp_tool_duration_seconds.clone()),
1975            Box::new(a2a_requests_total.clone()),
1976            Box::new(a2a_sessions_total.clone()),
1977            Box::new(nano_service_requests_total.clone()),
1978            Box::new(nano_service_duration_seconds.clone()),
1979            Box::new(plugin_failures_total.clone()),
1980            Box::new(plugin_callouts_total.clone()),
1981            Box::new(plugin_total.clone()),
1982            Box::new(tls_pq_handshakes_total.clone()),
1983            Box::new(rate_limiter_adaptive_factor.clone()),
1984            Box::new(rate_limiter_origin_signal_total.clone()),
1985            Box::new(rate_limiter_adaptive_tightened_total.clone()),
1986            Box::new(rate_limiter_adaptive_relaxed_total.clone()),
1987            Box::new(canary_promotions_total.clone()),
1988            Box::new(canary_rollbacks_total.clone()),
1989            Box::new(canary_weight.clone()),
1990            Box::new(mesh_connections_total.clone()),
1991            Box::new(spiffe_svid_refresh_total.clone()),
1992            Box::new(spiffe_svid_expiry_seconds.clone()),
1993            Box::new(l4_connections_total.clone()),
1994            Box::new(l4_connection_duration_seconds.clone()),
1995        ] {
1996            registry
1997                .register(m)
1998                .expect("fresh registry accepts every family exactly once");
1999        }
2000        Observability {
2001            registry,
2002            requests_total,
2003            request_duration,
2004            upstream_attempts_total,
2005            retries_total,
2006            hedge_sent_total,
2007            mirror_sent_total,
2008            rate_limited_total,
2009            shed_total,
2010            policy_dry_run_total,
2011            breaker_state,
2012            endpoint_health,
2013            fail_open_picks,
2014            jwks_refresh_total,
2015            active_requests,
2016            config_generation,
2017            rate_limiter_evictions,
2018            rate_limiter_live_keys,
2019            webhook_events_total,
2020            events_dropped,
2021            events_emitted,
2022            access_records_streamed_total,
2023            access_records_offered,
2024            access_records_dropped,
2025            access_records_sampled_dropped,
2026            websocket_policy_total,
2027            split_picks_total,
2028            sticky_sessions_total,
2029            cache_lookups_total,
2030            cache_stores_total,
2031            cache_revalidated_total,
2032            cache_purges_total,
2033            cache_entries,
2034            coalescing_leaders_total,
2035            coalescing_followers_total,
2036            coalescing_saved_upstream_calls_total,
2037            coalescing_waiters,
2038            quota_denied_total,
2039            quota_used,
2040            quota_limit,
2041            slo,
2042            admission_queued_total,
2043            admission_queue_depth,
2044            waf_total,
2045            anomaly_total,
2046            graphql_requests_total,
2047            graphql_depth,
2048            graphql_complexity,
2049            license_status,
2050            dns_discovery_endpoints,
2051            dns_discovery_refresh_total,
2052            dns_discovery_refresh_failures_total,
2053            config_convergence_generation,
2054            config_convergence_instances,
2055            config_convergence_drift,
2056            config_convergence_refresh_total,
2057            config_convergence_refresh_failures_total,
2058            ai_requests_total,
2059            ai_tokens_total,
2060            ai_cost_micros_total,
2061            ai_unknown_pricing_total,
2062            ai_stream_chunks_total,
2063            ai_budget_denied_total,
2064            ai_governance_denied_total,
2065            ai_guardrail_blocked_total,
2066            ai_semantic_cache_hits_total,
2067            ai_semantic_cache_misses_total,
2068            ai_routing_policy_escalations_total,
2069            ai_routing_policy_cheap_total,
2070            ai_routing_policy_latency_cost_selections_total,
2071            ai_experiment_variant_selections_total,
2072            ai_first_token_seconds,
2073            ai_stream_duration_seconds,
2074            ai_request_duration_seconds,
2075            ai_tokens_per_request,
2076            mcp_sessions_total,
2077            mcp_tool_calls_total,
2078            mcp_tool_duration_seconds,
2079            a2a_requests_total,
2080            a2a_sessions_total,
2081            nano_service_requests_total,
2082            nano_service_duration_seconds,
2083            plugin_failures_total,
2084            plugin_callouts_total,
2085            plugin_total,
2086            tls_pq_handshakes_total,
2087            rate_limiter_adaptive_factor,
2088            rate_limiter_origin_signal_total,
2089            rate_limiter_adaptive_tightened_total,
2090            rate_limiter_adaptive_relaxed_total,
2091            canary_promotions_total,
2092            canary_rollbacks_total,
2093            canary_weight,
2094            mesh_connections_total,
2095            spiffe_svid_refresh_total,
2096            spiffe_svid_expiry_seconds,
2097            l4_connections_total,
2098            l4_connection_duration_seconds,
2099            access_sample_bits: AtomicU64::new(1.0f64.to_bits()),
2100            rng: SampleRng::new(),
2101        }
2102    }
2103
2104    /// Access-log sample rate in [0.0, 1.0]. Env-driven at construction
2105    /// (`DWARA_ACCESS_LOG_SAMPLE`); settable directly for tests.
2106    pub fn set_access_sample(&self, rate: f64) {
2107        let rate = rate.clamp(0.0, 1.0);
2108        self.access_sample_bits
2109            .store(rate.to_bits(), Ordering::Relaxed);
2110    }
2111
2112    pub fn access_sample(&self) -> f64 {
2113        f64::from_bits(self.access_sample_bits.load(Ordering::Relaxed))
2114    }
2115
2116    /// Whether this request's access log line is emitted. Errors
2117    /// (status >= 500) are ALWAYS logged; everything else follows the
2118    /// configured sample rate.
2119    pub fn should_log_access(&self, status: u16) -> bool {
2120        if status >= 500 {
2121            return true;
2122        }
2123        let rate = self.access_sample();
2124        rate >= 1.0 || self.rng.next_f64() < rate
2125    }
2126
2127    /// Count and observe one completed request.
2128    pub fn record_request(&self, route: &str, listener: &str, status: u16, elapsed: Duration) {
2129        self.requests_total
2130            .with_label_values(&[route, listener, status_class(status)])
2131            .inc();
2132        self.request_duration
2133            .with_label_values(&[route])
2134            .observe(elapsed.as_secs_f64());
2135    }
2136
2137    /// Count one upstream send attempt (endpoint = "unpicked" when the
2138    /// dispatch never resolved an endpoint).
2139    pub fn record_upstream_attempt(&self, upstream: &str, endpoint: &str, status: u16) {
2140        self.upstream_attempts_total
2141            .with_label_values(&[upstream, endpoint, status_class(status)])
2142            .inc();
2143    }
2144
2145    /// Count one retry (a send attempt after the first).
2146    pub fn record_retry(&self, upstream: &str) {
2147        self.retries_total.with_label_values(&[upstream]).inc();
2148    }
2149
2150    /// Count one hedge copy sent (DW-063): a speculative duplicate the
2151    /// hedge timer fired. Called once per copy, not once per request.
2152    pub fn record_hedge_sent(&self, upstream: &str) {
2153        self.hedge_sent_total.with_label_values(&[upstream]).inc();
2154    }
2155
2156    /// Count one mirror (shadow traffic) request sent (DW-062).
2157    pub fn record_mirror_sent(&self, upstream: &str) {
2158        self.mirror_sent_total.with_label_values(&[upstream]).inc();
2159    }
2160
2161    /// Count one rate-limit denial (429).
2162    pub fn record_rate_limited(&self, route: &str) {
2163        self.rate_limited_total.with_label_values(&[route]).inc();
2164    }
2165
2166    /// Set the current adaptive rate-limit factor for a policy (DW-089).
2167    /// `factor` is the bounded `[min_factor, max_factor]` value the
2168    /// controller computed on its last `record_outcome`.
2169    pub fn set_adaptive_factor(&self, policy: &str, factor: f64) {
2170        self.rate_limiter_adaptive_factor
2171            .with_label_values(&[policy])
2172            .set(factor);
2173    }
2174
2175    /// Count one upstream origin signal received by the adaptive
2176    /// controller (DW-089). `signal` is the closed set `retry_after`.
2177    pub fn record_adaptive_origin_signal(&self, policy: &str, signal: &str) {
2178        self.rate_limiter_origin_signal_total
2179            .with_label_values(&[policy, signal])
2180            .inc();
2181    }
2182
2183    /// Count one adaptive factor decrease / tighten (DW-089).
2184    pub fn record_adaptive_tightened(&self, policy: &str) {
2185        self.rate_limiter_adaptive_tightened_total
2186            .with_label_values(&[policy])
2187            .inc();
2188    }
2189
2190    /// Count one adaptive factor increase / relax (DW-089).
2191    pub fn record_adaptive_relaxed(&self, policy: &str) {
2192        self.rate_limiter_adaptive_relaxed_total
2193            .with_label_values(&[policy])
2194            .inc();
2195    }
2196
2197    /// Count one canary promotion (DW-091), by group (config-bounded:
2198    /// service name or AI alias).
2199    pub fn record_canary_promotion(&self, group: &str) {
2200        self.canary_promotions_total
2201            .with_label_values(&[group])
2202            .inc();
2203    }
2204
2205    /// Count one canary rollback (DW-091), by group (config-bounded:
2206    /// service name or AI alias). Includes severe-regression
2207    /// immediate rollbacks to 0.
2208    pub fn record_canary_rollback(&self, group: &str) {
2209        self.canary_rollbacks_total
2210            .with_label_values(&[group])
2211            .inc();
2212    }
2213
2214    /// Set the current canary weight for a group (DW-091). Set on
2215    /// every applied adjustment; the total weight stays constant.
2216    pub fn set_canary_weight(&self, group: &str, weight: f64) {
2217        self.canary_weight.with_label_values(&[group]).set(weight);
2218    }
2219
2220    /// Count one sidecar connection proxied (DW-107), by mode and
2221    /// direction. `mode` is the closed set `sidecar`; `direction` is
2222    /// the closed set `inbound`, `outbound`. Inbound: terminate mTLS,
2223    /// apply policies, forward to the local app. Outbound: apply
2224    /// policies, wrap in mTLS, dial the remote sidecar.
2225    pub fn record_mesh_connection(&self, mode: &str, direction: &str) {
2226        self.mesh_connections_total
2227            .with_label_values(&[mode, direction])
2228            .inc();
2229    }
2230
2231    /// Count one SPIFFE SVID refresh attempt (DW-107), by result.
2232    /// `result` is the closed set `success`, `error`. An error keeps
2233    /// serving the previous SVID until it expires.
2234    pub fn record_spiffe_svid_refresh(&self, result: &str) {
2235        self.spiffe_svid_refresh_total
2236            .with_label_values(&[result])
2237            .inc();
2238    }
2239
2240    /// Set the seconds-until-expiry gauge for the current SPIFFE SVID
2241    /// (DW-107). Set on every successful refresh; counts down to 0 as
2242    /// the SVID nears expiry.
2243    pub fn set_spiffe_svid_expiry_seconds(&self, seconds: i64) {
2244        self.spiffe_svid_expiry_seconds.set(seconds);
2245    }
2246
2247    /// Count one quota denial (429, DW-033) by consumer and binding
2248    /// budget. Deliberately NOT the `rate_limited_total` family: budgets
2249    /// and rates are separate mechanisms (the issue's headline
2250    /// distinction); a quota wall is not a rate spike.
2251    pub fn record_quota_denied(&self, consumer: &str, budget: &str) {
2252        self.quota_denied_total
2253            .with_label_values(&[consumer, budget])
2254            .inc();
2255    }
2256
2257    /// Set a budget's current-window usage gauge (DW-033, scrape-time
2258    /// snapshot; `budget` is `daily` or `monthly`).
2259    pub fn set_quota_used(&self, consumer: &str, budget: &str, used: i64) {
2260        self.quota_used
2261            .with_label_values(&[consumer, budget])
2262            .set(used);
2263    }
2264
2265    /// Set a budget's configured cap gauge (DW-033, scrape-time).
2266    pub fn set_quota_limit(&self, consumer: &str, budget: &str, limit: i64) {
2267        self.quota_limit
2268            .with_label_values(&[consumer, budget])
2269            .set(limit);
2270    }
2271
2272    /// Count one gateway-cap shed (503), by priority class.
2273    pub fn record_shed(&self, priority: u8) {
2274        self.shed_total
2275            .with_label_values(&[&priority.to_string()])
2276            .inc();
2277    }
2278
2279    /// Count one admission queue outcome (DW-053). `outcome` is one of
2280    /// the closed set `admitted` (a permit was acquired after queueing),
2281    /// `timeout` (the queue timeout expired; the request was shed with
2282    /// 503), `queue_full` (the queue was at capacity; the request was
2283    /// shed immediately with 503).
2284    pub fn record_admission_queued(&self, outcome: &str) {
2285        self.admission_queued_total
2286            .with_label_values(&[outcome])
2287            .inc();
2288    }
2289
2290    /// Set the current admission queue depth gauge (DW-053). Called from
2291    /// the dataplane's atomic-backed depth counter (the hot path stays
2292    /// free of registry coupling — the same gauge model as the
2293    /// coalescing-waiters gauge).
2294    pub fn set_admission_queue_depth(&self, depth: i64) {
2295        self.admission_queue_depth.set(depth);
2296    }
2297
2298    /// Count one WAF-lite inspection outcome (DW-051). `route` is the
2299    /// matched route name; `filter` is the filter category that matched
2300    /// (`sqli`, `xss`, `path_traversal`) or `all` for the `passed`
2301    /// outcome (no match); `outcome` is `blocked` (403 returned),
2302    /// `logged` (dry-run match, request allowed), or `passed` (no
2303    /// match). All three labels are config-bounded (route names and the
2304    /// closed filter/outcome sets).
2305    pub fn record_waf(&self, route: &str, filter: &str, outcome: &str) {
2306        self.waf_total
2307            .with_label_values(&[route, filter, outcome])
2308            .inc();
2309    }
2310
2311    /// Count one anomaly-scoring inspection outcome (DW-090). `route` is
2312    /// the matched route name; `outcome` is `blocked` (403 returned),
2313    /// `logged` (dry-run match, request allowed), or `passed` (score
2314    /// below the threshold). Both labels are config-bounded (route names
2315    /// and the closed outcome set).
2316    pub fn record_anomaly(&self, route: &str, outcome: &str) {
2317        self.anomaly_total
2318            .with_label_values(&[route, outcome])
2319            .inc();
2320    }
2321
2322    /// Count one GraphQL check outcome (DW-099). `route` is the matched
2323    /// route name; `outcome` is one of the closed set `allowed`,
2324    /// `depth_exceeded`, `complexity_exceeded`,
2325    /// `persisted_query_required`. Both labels are config-bounded.
2326    pub fn record_graphql(&self, route: &str, outcome: &str) {
2327        self.graphql_requests_total
2328            .with_label_values(&[route, outcome])
2329            .inc();
2330    }
2331
2332    /// Set the last GraphQL query depth gauge (DW-099), by route.
2333    pub fn set_graphql_depth(&self, route: &str, depth: i64) {
2334        self.graphql_depth.with_label_values(&[route]).set(depth);
2335    }
2336
2337    /// Set the last GraphQL query complexity gauge (DW-099), by route.
2338    pub fn set_graphql_complexity(&self, route: &str, complexity: i64) {
2339        self.graphql_complexity
2340            .with_label_values(&[route])
2341            .set(complexity);
2342    }
2343
2344    /// Count one dry-run (monitor) would-have-rejected observation
2345    /// (DW-041): a policy phase evaluated a DENY (or shed, or limit
2346    /// violation) and the request was allowed anyway. `phase` is one of
2347    /// `route_limits`, `authz`, `rate_limit`, `load_shed`; `route` is
2348    /// the matched route name (`unrouted` for the pre-404 listener/
2349    /// global policy pass) — both config-bounded label spaces.
2350    pub fn record_policy_dry_run(&self, phase: &str, route: &str) {
2351        self.policy_dry_run_total
2352            .with_label_values(&[phase, route])
2353            .inc();
2354    }
2355
2356    /// The dry-run report's log side (DW-041): one structured warn
2357    /// event at target `dwara::policy` per would-have-rejected request,
2358    /// carrying the phase, the status the phase WOULD have answered, the
2359    /// route and consumer (when known), and the phase's own reason —
2360    /// the request-id correlation key ties it to the trace and access
2361    /// line of the request that was allowed. Field list is exhaustive
2362    /// and redacted by construction (no headers, no query string).
2363    pub fn emit_policy_dry_run(
2364        &self,
2365        phase: &str,
2366        would_be_status: u16,
2367        route: &str,
2368        consumer: Option<&str>,
2369        request_id: &str,
2370        detail: &str,
2371    ) {
2372        tracing::warn!(
2373            target: "dwara::policy",
2374            code = "policy_dry_run",
2375            phase = phase,
2376            would_be_status = would_be_status,
2377            route = route,
2378            consumer = consumer.unwrap_or("anonymous"),
2379            request_id = request_id,
2380            detail = detail,
2381            "policy would have rejected this request (dry-run); \
2382             enforcement of this phase skipped"
2383        );
2384    }
2385
2386    /// The JWKS refresh counter child for one provider (handed to the
2387    /// authenticator; incremented on every JWKS fetch attempt).
2388    pub fn jwks_refresh_counter(&self, provider: &str) -> IntCounter {
2389        self.jwks_refresh_total.with_label_values(&[provider])
2390    }
2391
2392    /// Count one response-cache lookup outcome (DW-037). `outcome` is
2393    /// one of the closed set `hit` / `stale` / `miss` / `bypass` (see
2394    /// `dataplane::response_cache` for the decision table).
2395    pub fn record_cache_lookup(&self, outcome: &str) {
2396        self.cache_lookups_total.with_label_values(&[outcome]).inc();
2397    }
2398
2399    /// Count one response-cache store outcome (DW-037). `outcome` is
2400    /// one of the closed set `stored` / `vetoed` / `over_cap` /
2401    /// `error`.
2402    pub fn record_cache_store(&self, outcome: &str) {
2403        self.cache_stores_total.with_label_values(&[outcome]).inc();
2404    }
2405
2406    /// Count one 304-confirmed revalidation served from the stored body
2407    /// (DW-037).
2408    pub fn record_cache_revalidated(&self) {
2409        self.cache_revalidated_total.inc();
2410    }
2411
2412    /// Count one cache purge (DW-037). `scope` is `route`, `all`,
2413    /// `tag`, `url`, or `url_prefix` (the DP-04 tag/url purge axes).
2414    pub fn record_cache_purge(&self, scope: &str) {
2415        self.cache_purges_total.with_label_values(&[scope]).inc();
2416    }
2417
2418    /// Set the approximate live-entries gauge (DW-037) — a scrape-time
2419    /// snapshot walk of the backing store (the same gauge model as the
2420    /// rate-limiter live-keys gauge: state lives outside observability,
2421    /// the dataplane walks it at scrape time).
2422    pub fn set_cache_entries(&self, entries: i64) {
2423        self.cache_entries.set(entries);
2424    }
2425
2426    /// DW-038: this cache miss became the coalescing leader.
2427    pub fn record_coalescing_leader(&self) {
2428        self.coalescing_leaders_total.inc();
2429    }
2430
2431    /// DW-038: a coalescing follower resolved — `outcome` is one of the
2432    /// closed set `served` / `fell_back_timeout` / `fell_back_unshared`
2433    /// / `fell_back_epoch` (see the field docs; callers pass literals).
2434    pub fn record_coalescing_follower(&self, outcome: &str) {
2435        self.coalescing_followers_total
2436            .with_label_values(&[outcome])
2437            .inc();
2438    }
2439
2440    /// DW-038: one upstream call avoided (a follower was served).
2441    pub fn record_coalescing_saved(&self) {
2442        self.coalescing_saved_upstream_calls_total.inc();
2443    }
2444
2445    /// DW-038: follower waiters gauge, +/- on park/resolve.
2446    pub fn coalescing_waiter(&self, parked: bool) {
2447        if parked {
2448            self.coalescing_waiters.inc();
2449        } else {
2450            self.coalescing_waiters.dec();
2451        }
2452    }
2453
2454    pub fn active_requests(&self) -> &IntGauge {
2455        &self.active_requests
2456    }
2457
2458    /// Publish the current config generation number.
2459    pub fn set_config_generation(&self, generation: u64) {
2460        self.config_generation.set(generation as i64);
2461    }
2462
2463    /// Set the `dwara_license_status` gauge (DW-032): 0 = no license
2464    /// (OSS), 1 = valid, 2 = expired within grace, 3 = expired past
2465    /// grace (degraded to OSS). Set once at startup and on every reload.
2466    pub fn set_license_status(&self, status: i64) {
2467        self.license_status.set(status);
2468    }
2469
2470    /// Set the `dwara_dns_discovery_endpoints` gauge for one upstream
2471    /// (DW-042): the current resolved endpoint count. The `upstream`
2472    /// label is config-bounded; cardinality is never per resolved
2473    /// address. Set by the discovery task after each refresh.
2474    pub fn set_dns_discovery_endpoints(&self, upstream: &str, count: i64) {
2475        self.dns_discovery_endpoints
2476            .with_label_values(&[upstream])
2477            .set(count);
2478    }
2479
2480    /// Count one DNS discovery refresh attempt (DW-042) in
2481    /// `dwara_dns_discovery_refresh_total{upstream}`. The `upstream`
2482    /// label is config-bounded.
2483    pub fn record_dns_discovery_refresh(&self, upstream: &str) {
2484        self.dns_discovery_refresh_total
2485            .with_label_values(&[upstream])
2486            .inc();
2487    }
2488
2489    /// Count one DNS discovery refresh failure (DW-042) in
2490    /// `dwara_dns_discovery_refresh_failures_total{upstream}`. The
2491    /// `upstream` label is config-bounded.
2492    pub fn record_dns_discovery_refresh_failure(&self, upstream: &str) {
2493        self.dns_discovery_refresh_failures_total
2494            .with_label_values(&[upstream])
2495            .inc();
2496    }
2497
2498    /// Set this instance's current convergence generation (DW-054) in
2499    /// `dwara_config_convergence_generation{instance}`. The instance
2500    /// label is config-bounded (one series per process). Set by the
2501    /// convergence coordinator after every local or remote publish.
2502    pub fn set_config_convergence_generation(&self, instance: &str, generation: i64) {
2503        self.config_convergence_generation
2504            .with_label_values(&[instance])
2505            .set(generation);
2506    }
2507
2508    /// Set the total instances the convergence backend reports
2509    /// (DW-054) in `dwara_config_convergence_instances`. Set by the
2510    /// coordinator's drift check from the cluster view.
2511    pub fn set_config_convergence_instances(&self, count: i64) {
2512        self.config_convergence_instances.set(count);
2513    }
2514
2515    /// Set the convergence drift flag (DW-054) in
2516    /// `dwara_config_convergence_drift`: 1 when one or more instances
2517    /// serve a different config hash than the majority, 0 when
2518    /// converged. Set by the coordinator's drift check.
2519    pub fn set_config_convergence_drift(&self, drift: bool) {
2520        self.config_convergence_drift.set(i64::from(drift));
2521    }
2522
2523    /// Count one convergence refresh attempt (DW-054) in
2524    /// `dwara_config_convergence_refresh_total`.
2525    pub fn record_config_convergence_refresh(&self) {
2526        self.config_convergence_refresh_total.inc();
2527    }
2528
2529    /// Count one AI budget denial (DW-078) in
2530    /// `dwara_ai_budget_denied_total{kind}` (kind: tokens | cost).
2531    pub fn record_ai_budget_denied(&self, kind: &str) {
2532        self.ai_budget_denied_total.with_label_values(&[kind]).inc();
2533    }
2534
2535    /// Count one AI governance denial (DW-084) in
2536    /// `dwara_ai_governance_denied_total{reason}` (reason: the denial
2537    /// cause string, config-bounded — no consumer/model labels).
2538    pub fn record_ai_governance_denied(&self, reason: &str) {
2539        self.ai_governance_denied_total
2540            .with_label_values(&[reason])
2541            .inc();
2542    }
2543
2544    /// Count one AI guardrail block (DW-082) in
2545    /// `dwara_ai_guardrail_blocked_total{rule,kind}` (rule: the rule
2546    /// name, config-bounded; kind: the phase/kind label — prompt,
2547    /// response, schema, pii, injection, banned). No consumer label
2548    /// (cardinality rule).
2549    pub fn record_ai_guardrail_blocked(&self, rule: &str, kind: &str) {
2550        self.ai_guardrail_blocked_total
2551            .with_label_values(&[rule, kind])
2552            .inc();
2553    }
2554
2555    /// Count one AI semantic cache hit (DW-083) in
2556    /// `dwara_ai_semantic_cache_hits_total{model}` (model: the
2557    /// client-facing alias, config-bounded). No consumer label
2558    /// (cardinality rule).
2559    pub fn record_ai_semantic_cache_hit(&self, model: &str) {
2560        self.ai_semantic_cache_hits_total
2561            .with_label_values(&[model])
2562            .inc();
2563    }
2564
2565    /// Count one AI semantic cache miss (DW-083) in
2566    /// `dwara_ai_semantic_cache_misses_total{model}` (model: the
2567    /// client-facing alias, config-bounded). No consumer label
2568    /// (cardinality rule).
2569    pub fn record_ai_semantic_cache_miss(&self, model: &str) {
2570        self.ai_semantic_cache_misses_total
2571            .with_label_values(&[model])
2572            .inc();
2573    }
2574
2575    /// Count one AI routing-policy FallbackChain escalation (DW-085)
2576    /// in `dwara_ai_routing_policy_escalations_total{policy}` (policy:
2577    /// the routing-policy name, config-bounded). No consumer label
2578    /// (cardinality rule).
2579    pub fn record_ai_routing_policy_escalation(&self, policy: &str) {
2580        self.ai_routing_policy_escalations_total
2581            .with_label_values(&[policy])
2582            .inc();
2583    }
2584
2585    /// Count one AI routing-policy FallbackChain cheap-model
2586    /// selection (DW-085) in
2587    /// `dwara_ai_routing_policy_cheap_total{policy}` (policy: the
2588    /// routing-policy name, config-bounded). No consumer label
2589    /// (cardinality rule).
2590    pub fn record_ai_routing_policy_cheap(&self, policy: &str) {
2591        self.ai_routing_policy_cheap_total
2592            .with_label_values(&[policy])
2593            .inc();
2594    }
2595
2596    /// Count one AI routing-policy LatencyCost selection (DW-085) in
2597    /// `dwara_ai_routing_policy_latency_cost_selections_total{policy}`
2598    /// (policy: the routing-policy name, config-bounded). No consumer
2599    /// label (cardinality rule).
2600    pub fn record_ai_routing_policy_latency_cost_selection(&self, policy: &str) {
2601        self.ai_routing_policy_latency_cost_selections_total
2602            .with_label_values(&[policy])
2603            .inc();
2604    }
2605
2606    /// Count one AI experiment (A/B test) variant selection (DW-086)
2607    /// in `dwara_ai_experiment_variant_selections_total{experiment,
2608    /// variant}` (experiment: the A/B test name, variant: the
2609    /// selected variant name, both config-bounded labels). No
2610    /// consumer label (cardinality rule).
2611    pub fn record_ai_experiment_variant_selection(&self, experiment: &str, variant: &str) {
2612        self.ai_experiment_variant_selections_total
2613            .with_label_values(&[experiment, variant])
2614            .inc();
2615    }
2616
2617    /// Count forwarded streaming chunks (DW-077) in
2618    /// `dwara_ai_stream_chunks_total{provider}`.
2619    pub fn record_ai_stream_chunk(&self, provider: &str) {
2620        self.ai_stream_chunks_total
2621            .with_label_values(&[provider])
2622            .inc();
2623    }
2624
2625    /// Record the time to a stream's first forwarded chunk (DW-077) in
2626    /// `dwara_ai_first_token_seconds{provider}`. Call once per stream.
2627    pub fn record_ai_first_token(&self, provider: &str, seconds: f64) {
2628        self.ai_first_token_seconds
2629            .with_label_values(&[provider])
2630            .observe(seconds);
2631    }
2632
2633    /// Record a completed stream's total duration (DW-077) in
2634    /// `dwara_ai_stream_duration_seconds{provider}`.
2635    pub fn record_ai_stream_end(&self, provider: &str, seconds: f64) {
2636        self.ai_stream_duration_seconds
2637            .with_label_values(&[provider])
2638            .observe(seconds);
2639    }
2640
2641    /// Record one AI request's end-to-end latency (AI-07) in
2642    /// `dwara_ai_request_duration_seconds{provider,route}`. `provider`
2643    /// and `route` are config-bounded labels. Call once per completed
2644    /// AI request (success and provider/translation errors alike);
2645    /// client-side rejections never reach a provider and are not
2646    /// observed here.
2647    pub fn record_ai_request_duration(&self, provider: &str, route: &str, seconds: f64) {
2648        self.ai_request_duration_seconds
2649            .with_label_values(&[provider, route])
2650            .observe(seconds);
2651    }
2652
2653    /// Observe the per-request token distribution (AI-07) in
2654    /// `dwara_ai_tokens_per_request{provider,model,kind}`. `kind` is
2655    /// the closed set `prompt` | `completion`. Call once per request
2656    /// that reported usage, for each direction that has a value
2657    /// (provider-reported only; the gateway never estimates). `model`
2658    /// is the provider's own model identifier (config-bounded).
2659    pub fn record_ai_tokens_per_request(
2660        &self,
2661        provider: &str,
2662        model: &str,
2663        prompt: u64,
2664        completion: u64,
2665    ) {
2666        if prompt > 0 {
2667            self.ai_tokens_per_request
2668                .with_label_values(&[provider, model, "prompt"])
2669                .observe(prompt as f64);
2670        }
2671        if completion > 0 {
2672            self.ai_tokens_per_request
2673                .with_label_values(&[provider, model, "completion"])
2674                .observe(completion as f64);
2675        }
2676    }
2677
2678    /// Count one MCP session lifecycle transition (DW-087) in
2679    /// `dwara_mcp_sessions_total{state}`. `state` is one of the closed
2680    /// set `initialized` (a new session created by `initialize`),
2681    /// `closed` (a session deleted by `shutdown`), or `expired` (a
2682    /// session reaped by the TTL cleanup).
2683    pub fn record_mcp_session(&self, state: &str) {
2684        self.mcp_sessions_total.with_label_values(&[state]).inc();
2685    }
2686
2687    /// Count one MCP tool call (DW-087) in
2688    /// `dwara_mcp_tool_calls_total{tool,status}`. `tool` is the
2689    /// config-declared tool name (config-bounded); `status` is one of
2690    /// the closed set `success`, `error`, `denied`. No consumer label
2691    /// (cardinality rule); the consumer is in the access log and the
2692    /// analytics `mcp_tool_calls` table.
2693    pub fn record_mcp_tool_call(&self, tool: &str, status: &str) {
2694        self.mcp_tool_calls_total
2695            .with_label_values(&[tool, status])
2696            .inc();
2697    }
2698
2699    /// Record an MCP tool call duration (DW-087) in
2700    /// `dwara_mcp_tool_duration_seconds{tool}`. `tool` is the
2701    /// config-declared tool name (config-bounded label). Call once per
2702    /// tool call (success, error, and denied alike).
2703    pub fn record_mcp_tool_duration(&self, tool: &str, seconds: f64) {
2704        self.mcp_tool_duration_seconds
2705            .with_label_values(&[tool])
2706            .observe(seconds);
2707    }
2708
2709    /// Count one A2A (agent-to-agent) request (DW-114) in
2710    /// `dwara_a2a_requests_total{agent,outcome}`. `agent` is the
2711    /// config-declared agent name (config-bounded); `outcome` is one
2712    /// of the closed set `success`, `error`, `stubbed` (`stubbed` =
2713    /// the task lifecycle is not yet implemented pending spec freeze).
2714    /// No consumer label (cardinality rule); the consumer is in the
2715    /// access log.
2716    pub fn record_a2a_request(&self, agent: &str, outcome: &str) {
2717        self.a2a_requests_total
2718            .with_label_values(&[agent, outcome])
2719            .inc();
2720    }
2721
2722    /// Count one A2A session lifecycle transition (DW-114) in
2723    /// `dwara_a2a_sessions_total{state}`. `state` is one of the closed
2724    /// set `initialized` (a new task session created), `closed` (a
2725    /// session deleted), or `expired` (a session reaped by the TTL
2726    /// cleanup). Mirrors MCP's session counter.
2727    pub fn record_a2a_session(&self, state: &str) {
2728        self.a2a_sessions_total.with_label_values(&[state]).inc();
2729    }
2730
2731    /// Count one nano-service (WASM route handler) request outcome
2732    /// (DW-106) in `dwara_nano_service_requests_total{route,outcome}`.
2733    /// `route` is the matched route name (config-bounded); `outcome` is
2734    /// one of the closed set `success`, `error`, `timeout`. No consumer
2735    /// label (cardinality rule); the consumer is in the access log.
2736    pub fn record_nano_service_request(&self, route: &str, outcome: &str) {
2737        self.nano_service_requests_total
2738            .with_label_values(&[route, outcome])
2739            .inc();
2740    }
2741
2742    /// Record a nano-service `handle` call duration (DW-106) in
2743    /// `dwara_nano_service_duration_seconds{route}`. `route` is the
2744    /// matched route name (config-bounded label). Call once per request
2745    /// (success, error, and timeout alike).
2746    pub fn record_nano_service_duration(&self, route: &str, seconds: f64) {
2747        self.nano_service_duration_seconds
2748            .with_label_values(&[route])
2749            .observe(seconds);
2750    }
2751
2752    /// Count one plugin dispatch failure (DW-157) in
2753    /// `dwara_plugin_failures_total{name,reason}`. `name` is the
2754    /// config-declared plugin name (config-bounded); `reason` is one of
2755    /// the closed set `crashed`, `disabled`, `not_loaded`,
2756    /// `not_registered`, `instantiate_failed`, `trap`,
2757    /// `body_too_large`, `response_stream_ended`. Counted once per
2758    /// request the plugin chain
2759    /// answered with an error — a plugin's `send_http_response`
2760    /// short-circuit is a decision, not a failure, and is not counted
2761    /// (the access-log `plugin_short_circuit` flag marks those).
2762    pub fn record_plugin_failure(&self, name: &str, reason: &str) {
2763        self.plugin_failures_total
2764            .with_label_values(&[name, reason])
2765            .inc();
2766    }
2767
2768    /// Count one plugin HTTP callout outcome (DW-167) in
2769    /// `dwara_plugin_callouts_total{name,outcome}`. `name` is the
2770    /// config-declared plugin name (config-bounded); `outcome` is one
2771    /// of the closed set `ok` (a completed response was delivered to
2772    /// the plugin, any status), `timeout`, `error`, `loop_guard`.
2773    /// Counted once per performed callout (`loop_guard` once per guard
2774    /// trip); a callout ERROR additionally increments
2775    /// `dwara_plugin_failures_total{name,reason=callout_failed}`
2776    /// because the route fails closed.
2777    pub fn record_plugin_callout(&self, name: &str, outcome: &str) {
2778        self.plugin_callouts_total
2779            .with_label_values(&[name, outcome])
2780            .inc();
2781    }
2782
2783    /// Set one state's plugin count (DW-158) in
2784    /// `dwara_plugin_total{state}`. `state` is one of the closed set
2785    /// `healthy`, `crashed`, `disabled`, `not_loaded`,
2786    /// `not_registered`. The dataplane sets every state's series on
2787    /// each publish (`reload_plugins`), so a reload that removes the
2788    /// last plugin of a state drives its series back to zero instead of
2789    /// stranding the previous count.
2790    pub fn set_plugin_state_total(&self, state: &str, total: i64) {
2791        self.plugin_total.with_label_values(&[state]).set(total);
2792    }
2793
2794    /// Count one post-quantum TLS handshake outcome (DW-105) in
2795    /// `dwara_tls_pq_handshakes_total{result}`. `result` is one of the
2796    /// closed set `success` (the X25519+ML-KEM hybrid kx group was
2797    /// negotiated), `fallback` (the client did not support the hybrid
2798    /// group and a classical group was negotiated), or `disabled` (PQ
2799    /// was off or the experimental API was inert). Use
2800    /// `security::pq::pq_handshake_metric` to derive the label
2801    /// from a `security::pq::PqHandshakeResult`.
2802    pub fn record_tls_pq_handshake(&self, result: &str) {
2803        self.tls_pq_handshakes_total
2804            .with_label_values(&[result])
2805            .inc();
2806    }
2807
2808    /// Count one convergence refresh failure (DW-054) in
2809    /// `dwara_config_convergence_refresh_failures_total`.
2810    pub fn record_config_convergence_refresh_failure(&self) {
2811        self.config_convergence_refresh_failures_total.inc();
2812    }
2813
2814    /// Count one AI route request outcome (DW-075) in
2815    /// `dwara_ai_requests_total{provider,route,outcome,version}`. The
2816    /// provider, route, and version labels are config-bounded
2817    /// (`version` is the canary version that served, DW-076, or
2818    /// "default" for non-canary aliases).
2819    pub fn record_ai_request(&self, provider: &str, route: &str, outcome: &str, version: &str) {
2820        self.ai_requests_total
2821            .with_label_values(&[provider, route, outcome, version])
2822            .inc();
2823    }
2824
2825    /// Add provider-reported token usage (DW-075) to
2826    /// `dwara_ai_tokens_total{provider,kind,version}` (kind: prompt |
2827    /// completion; version: the canary version that served, DW-076, or
2828    /// "default"). Provider-reported values only.
2829    pub fn record_ai_tokens(&self, provider: &str, prompt: u64, completion: u64, version: &str) {
2830        let counters = self
2831            .ai_tokens_total
2832            .with_label_values(&[provider, "prompt", version]);
2833        counters.inc_by(prompt);
2834        let counters = self
2835            .ai_tokens_total
2836            .with_label_values(&[provider, "completion", version]);
2837        counters.inc_by(completion);
2838    }
2839
2840    /// Add priced AI spend (DW-079) to
2841    /// `dwara_ai_cost_micros_total{provider,model}` in micro-USD. The
2842    /// provider and model labels are config-bounded (no consumer
2843    /// label — cardinality rule).
2844    pub fn record_ai_cost(&self, provider: &str, model: &str, cost_micros: u64) {
2845        self.ai_cost_micros_total
2846            .with_label_values(&[provider, model])
2847            .inc_by(cost_micros);
2848    }
2849
2850    /// Count one request served by a model with no pricing entry
2851    /// (DW-AI-03) under the `alert` unknown-model policy. The model
2852    /// label is the provider model identifier (config-bounded).
2853    pub fn record_ai_unknown_pricing(&self, model: &str) {
2854        self.ai_unknown_pricing_total
2855            .with_label_values(&[model])
2856            .inc();
2857    }
2858
2859    /// The current `dwara_config_convergence_drift` gauge value (DW-054):
2860    /// 1 when drift was last reported, 0 when converged. Test seam +
2861    /// operator introspection (the gauge is also scraped via /metrics).
2862    pub fn config_convergence_drift(&self) -> i64 {
2863        self.config_convergence_drift.get()
2864    }
2865
2866    /// The current `dwara_config_convergence_instances` gauge value
2867    /// (DW-054): the last cluster-view instance count. Test seam +
2868    /// operator introspection.
2869    pub fn config_convergence_instances(&self) -> i64 {
2870        self.config_convergence_instances.get()
2871    }
2872
2873    /// Set the `breaker_state` gauge for one upstream (0 closed, 1 open,
2874    /// 2 half-open). Scrape-time snapshot setter: the walk that computes
2875    /// the values from live upstream state lives on the dataplane (see
2876    /// `refresh_observation_gauges` in `dataplane::upstream`), keeping
2877    /// this module free of runtime-state dependencies — it only records
2878    /// what it is handed.
2879    pub fn set_breaker_state(&self, upstream: &str, state: i64) {
2880        self.breaker_state.with_label_values(&[upstream]).set(state);
2881    }
2882
2883    /// Set the `upstream_fail_open_picks` gauge for one upstream
2884    /// (scrape-time snapshot of the balancer's monotonic fail-open
2885    /// counter; a gauge rather than a counter so the hot pick path stays
2886    /// free of metrics coupling).
2887    pub fn set_fail_open_picks(&self, upstream: &str, picks: i64) {
2888        self.fail_open_picks
2889            .with_label_values(&[upstream])
2890            .set(picks);
2891    }
2892
2893    /// Set the `dwara_rate_limiter_evictions_total` gauge: aggregate
2894    /// cells dropped by eviction sweeps across every compiled rule
2895    /// (#132). Scrape-time snapshot of the engine's monotonic counter —
2896    /// a gauge rather than a counter so the hot check path stays free
2897    /// of metrics coupling; the value resets when a reload rebuilds the
2898    /// engine. See [`Self::set_breaker_state`] for the refresh model.
2899    pub fn set_rate_limiter_evictions(&self, evictions: i64) {
2900        self.rate_limiter_evictions.set(evictions);
2901    }
2902
2903    /// Set the `dwara_rate_limiter_live_keys` gauge: live per-key cells
2904    /// across every compiled rule, approximate under concurrent checks
2905    /// and bounded by the sharded store cap (#132). Aggregate and
2906    /// unlabeled by design — cardinality is never per key. Scrape-time
2907    /// snapshot setter; see [`Self::set_breaker_state`].
2908    pub fn set_rate_limiter_live_keys(&self, keys: i64) {
2909        self.rate_limiter_live_keys.set(keys);
2910    }
2911
2912    /// Count one webhook delivery outcome (DW-044) in
2913    /// `dwara_webhook_events_total{kind,outcome}`. `kind` is the
2914    /// event-kind spelling, `outcome` one of `delivered`, `failed`,
2915    /// `dropped` (the deliverer is the only caller, so both label
2916    /// spaces stay the closed sets documented on the family).
2917    pub fn record_webhook_event(&self, kind: &str, outcome: &str) {
2918        self.webhook_events_total
2919            .with_label_values(&[kind, outcome])
2920            .inc();
2921    }
2922
2923    /// Set the `dwara_events_dropped_total` gauge (DW-044): events
2924    /// dropped at EMIT time. Scrape-time snapshot of the event bus's
2925    /// monotonic counter; see [`Self::set_rate_limiter_evictions`] for
2926    /// the gauge-not-counter model.
2927    pub fn set_events_dropped(&self, dropped: i64) {
2928        self.events_dropped.set(dropped);
2929    }
2930
2931    /// Set the `dwara_events_emitted_total` gauge (DW-044): events
2932    /// handed to the event queue since process start. Scrape-time
2933    /// snapshot setter; see [`Self::set_events_dropped`].
2934    pub fn set_events_emitted(&self, emitted: i64) {
2935        self.events_emitted.set(emitted);
2936    }
2937
2938    /// Count `records` access records into
2939    /// `dwara_access_records_streamed_total{outcome}` (DW-121). The
2940    /// stream flusher is the only caller; `outcome` is one of
2941    /// `delivered`, `failed`, `dropped` (the closed set documented on
2942    /// the family), counted at BATCH granularity by the records each
2943    /// flushed batch carried.
2944    pub fn record_access_stream(&self, outcome: &str, records: u64) {
2945        self.access_records_streamed_total
2946            .with_label_values(&[outcome])
2947            .inc_by(records);
2948    }
2949
2950    /// Set the `dwara_access_records_offered_total` gauge (DW-121):
2951    /// records offered to the stream channel since process start.
2952    /// Scrape-time snapshot of the stream's monotonic counter; see
2953    /// [`Self::set_events_dropped`] for the gauge-not-counter model.
2954    pub fn set_access_records_offered(&self, offered: i64) {
2955        self.access_records_offered.set(offered);
2956    }
2957
2958    /// Set the `dwara_access_records_dropped_total` gauge (DW-121):
2959    /// records dropped at OFFER time. Scrape-time snapshot setter; see
2960    /// [`Self::set_access_records_offered`].
2961    pub fn set_access_records_dropped(&self, dropped: i64) {
2962        self.access_records_dropped.set(dropped);
2963    }
2964
2965    /// Set the `dwara_access_records_sampled_dropped_total` gauge
2966    /// (REL-10, #221): records dropped by sampled degradation.
2967    /// Scrape-time snapshot setter; see
2968    /// [`Self::set_access_records_dropped`].
2969    pub fn set_access_records_sampled_dropped(&self, dropped: i64) {
2970        self.access_records_sampled_dropped.set(dropped);
2971    }
2972
2973    /// Count one split dispatch decision (DW-040) in
2974    /// `dwara_split_picks_total{service,upstream}`. Both labels are
2975    /// config-declared names; the caller is the dispatch path.
2976    pub fn record_split_pick(&self, service: &str, upstream: &str) {
2977        self.split_picks_total
2978            .with_label_values(&[service, upstream])
2979            .inc();
2980    }
2981
2982    /// Count one minted sticky session (DW-040).
2983    pub fn record_sticky_session(&self) {
2984        self.sticky_sessions_total.inc();
2985    }
2986
2987    /// Count one WebSocket policy decision (DW-039) in
2988    /// `dwara_websocket_policy_total{route,outcome}`. `outcome` is one
2989    /// of `origin_denied`, `rate_closed` (the closed set documented on
2990    /// the family); `route` is the config-declared route name.
2991    pub fn record_websocket_policy(&self, route: &str, outcome: &str) {
2992        self.websocket_policy_total
2993            .with_label_values(&[route, outcome])
2994            .inc();
2995    }
2996
2997    /// Set the `endpoint_health` gauge for one endpoint (1 available,
2998    /// 0 ejected). Scrape-time snapshot setter; see
2999    /// [`Self::set_breaker_state`] for the refresh model.
3000    pub fn set_endpoint_health(&self, upstream: &str, endpoint: &str, up: bool) {
3001        self.endpoint_health
3002            .with_label_values(&[upstream, endpoint])
3003            .set(up as i64);
3004    }
3005
3006    /// Render every family in Prometheus text format.
3007    pub fn render(&self) -> String {
3008        let mut buf = Vec::new();
3009        let encoder = TextEncoder::new();
3010        let families = self.registry.gather();
3011        encoder
3012            .encode(&families, &mut buf)
3013            .expect("text encoding of gathered families cannot fail");
3014        String::from_utf8(buf).expect("prometheus text format is ASCII")
3015    }
3016
3017    /// Gather the raw metric families (DW-073: OTLP metrics export).
3018    /// Used by the OTLP metrics exporter to convert prometheus metric
3019    /// families into OTLP protobuf for export to a collector.
3020    pub fn gather(&self) -> Vec<prometheus::proto::MetricFamily> {
3021        self.registry.gather()
3022    }
3023}
3024
3025// --- OTel GenAI semantic conventions (AI-07) -------------------------------
3026
3027/// Record the request-side OTel GenAI semantic-convention attributes
3028/// (AI-07) onto an AI span. The span MUST have been created with every
3029/// `gen_ai.*` field declared as `tracing::field::Empty` (see
3030/// `gen_ai_span_fields`); `Span::record` only fills fields the span's
3031/// metadata already declared. Request-side fields are always available
3032/// once the canonical [`crate::ai::types::ChatRequest`] is parsed; the
3033/// `system` is the serving provider's kind
3034/// ([`crate::config::ai::AiProviderKind::gen_ai_system`]).
3035pub fn record_gen_ai_request_attrs(
3036    span: &tracing::Span,
3037    system: &str,
3038    request_model: &str,
3039    max_tokens: Option<u64>,
3040    temperature: Option<f64>,
3041    top_p: Option<f64>,
3042) {
3043    span.record("gen_ai.system", system);
3044    span.record("gen_ai.request.model", request_model);
3045    if let Some(m) = max_tokens {
3046        span.record("gen_ai.request.max_tokens", m);
3047    }
3048    if let Some(t) = temperature {
3049        span.record("gen_ai.request.temperature", t);
3050    }
3051    if let Some(p) = top_p {
3052        span.record("gen_ai.request.top_p", p);
3053    }
3054}
3055
3056/// Record the response-side OTel GenAI semantic-convention attributes
3057/// (AI-07) onto an AI span. Each argument is recorded only when present
3058/// (provider-reported only; the gateway never estimates). `finish_reasons`
3059/// is the per-choice finish-reason token list (one entry per choice).
3060pub fn record_gen_ai_response_attrs(
3061    span: &tracing::Span,
3062    response_model: Option<&str>,
3063    prompt_tokens: Option<u64>,
3064    completion_tokens: Option<u64>,
3065    total_tokens: Option<u64>,
3066    finish_reasons: &[&str],
3067    response_id: Option<&str>,
3068) {
3069    if let Some(m) = response_model {
3070        span.record("gen_ai.response.model", m);
3071    }
3072    if let Some(p) = prompt_tokens {
3073        span.record("gen_ai.usage.prompt_tokens", p);
3074    }
3075    if let Some(c) = completion_tokens {
3076        span.record("gen_ai.usage.completion_tokens", c);
3077    }
3078    if let Some(t) = total_tokens {
3079        span.record("gen_ai.usage.total_tokens", t);
3080    }
3081    if !finish_reasons.is_empty() {
3082        span.record(
3083            "gen_ai.response.finish_reasons",
3084            tracing::field::display(finish_reasons.join(",")),
3085        );
3086    }
3087    if let Some(id) = response_id {
3088        span.record("gen_ai.response.id", id);
3089    }
3090}
3091
3092/// Echo a request ID onto a response (insert, never append: exactly one
3093/// `X-Request-Id`, the gateway's resolved one).
3094pub fn stamp_request_id(headers: &mut HeaderMap, request_id: &str) {
3095    if let Ok(v) = HeaderValue::from_str(request_id) {
3096        headers.insert(&X_REQUEST_ID, v);
3097    }
3098}
3099
3100/// Resolve the request's business correlation ID (DW-093): a valid
3101/// inbound `X-Correlation-Id` verbatim, else the already-resolved
3102/// `request_id` (so every request carries a non-empty correlation
3103/// handle for the journey/funnel query even when the client did not
3104/// send one). The validity gate mirrors `valid_inbound_request_id`
3105/// (printable ASCII, at most 128 bytes) so hostile IDs cannot smuggle
3106/// control characters into the analytics store.
3107pub fn resolve_correlation_id(headers: &HeaderMap, request_id: &str) -> String {
3108    if let Some(v) = headers.get(&X_CORRELATION_ID) {
3109        if valid_inbound_request_id(v.as_bytes()) {
3110            if let Ok(s) = v.to_str() {
3111                return s.to_string();
3112            }
3113        }
3114    }
3115    request_id.to_string()
3116}
3117
3118/// Echo the correlation ID onto a response (insert, never append:
3119/// exactly one `X-Correlation-Id`, the gateway's resolved one).
3120pub fn stamp_correlation_id(headers: &mut HeaderMap, correlation_id: &str) {
3121    if let Ok(v) = HeaderValue::from_str(correlation_id) {
3122        headers.insert(&X_CORRELATION_ID, v);
3123    }
3124}
3125
3126// --- SLO & error budgets (DW-052) -----------------------------------------
3127//
3128// Per-route objectives (config: routes[].slo) exported as burn-rate
3129// metrics for multiwindow alerting. Availability counts a request BAD
3130// when the gateway answered 5xx (client errors are the caller's
3131// policy, not availability); latency counts a request BAD when the
3132// end-to-end duration exceeds the configured threshold. Burn rate is
3133// the standard error-budget consumption signal:
3134//
3135//   burn = (bad_fraction over window) / (1 - target)
3136//
3137// 1.0 = consuming budget at exactly the allowed rate (the budget lasts
3138// its whole period); 14.4 over 1h = the 28-day budget exhausted in
3139// ~49 hours (the classic fast-burn page); 6 over 1h the slow-burn
3140// signal. The exported windows are the alerting pair 5m and 1h.
3141//
3142// Values are computed from in-process sliding-window counters (15 s
3143// buckets) and published at SCRAPE time through a custom Collector —
3144// always fresh, never a stale sampled gauge, and no background task.
3145// The windows are process-local: they start empty at boot (documented
3146// — burn rates become meaningful as traffic accumulates, and a restart
3147// is itself an availability event the SLOs of OTHER processes catch).
3148
3149/// Plain per-route SLO targets. Primitives on purpose: observability
3150/// depends on nothing, so the config types are converted at the call
3151/// site (dataplane refresh).
3152#[derive(Debug, Clone, Copy, PartialEq)]
3153pub struct SloTargets {
3154    /// Availability target as a fraction in (0, 1]: the share of
3155    /// requests that must NOT be a gateway-answered 5xx.
3156    pub availability: f64,
3157    /// Latency objective threshold in milliseconds. `None` = no
3158    /// latency objective for this route.
3159    pub latency_threshold_ms: Option<f64>,
3160    /// Latency target as a fraction in (0, 1): the share of requests
3161    /// that must complete within `latency_threshold_ms`.
3162    pub latency_target: f64,
3163}
3164
3165/// Sliding-window bucket width (15 s): the 5m window is 20 buckets,
3166/// the 1h window 240 — per route, trivially small, and granular
3167/// enough that scrape-time computation is never more than one bucket
3168/// stale.
3169const SLO_BUCKET_MS: i64 = 15_000;
3170
3171/// The exported windows: (milliseconds, label). The standard
3172/// multiwindow-burn-rate alerting pair.
3173const SLO_WINDOWS: [(i64, &str); 2] = [(300_000, "5m"), (3_600_000, "1h")];
3174
3175#[derive(Debug, Default, Clone, Copy)]
3176struct SloBucket {
3177    start_ms: i64,
3178    total: u64,
3179    errors: u64,
3180    over_latency: u64,
3181}
3182
3183#[derive(Debug)]
3184struct SloRouteState {
3185    targets: SloTargets,
3186    /// One ring per [`SLO_WINDOWS`] entry; the ring at index i has
3187    /// `SLO_WINDOWS[i].0 / SLO_BUCKET_MS` buckets.
3188    rings: Vec<Vec<SloBucket>>,
3189}
3190
3191impl SloRouteState {
3192    fn new(targets: SloTargets) -> Self {
3193        let rings = SLO_WINDOWS
3194            .iter()
3195            .map(|(ms, _)| {
3196                let n = (*ms / SLO_BUCKET_MS) as usize;
3197                vec![SloBucket::default(); n.max(1)]
3198            })
3199            .collect();
3200        SloRouteState { targets, rings }
3201    }
3202
3203    /// Record one outcome into every ring (each ring buckets
3204    /// independently by its own wraparound index).
3205    fn record(&mut self, now_ms: i64, error: bool, over_latency: bool) {
3206        let idx = (now_ms / SLO_BUCKET_MS) as usize;
3207        let stamp = idx as i64 * SLO_BUCKET_MS;
3208        for ring in &mut self.rings {
3209            let slot = idx % ring.len();
3210            let b = &mut ring[slot];
3211            if b.start_ms != stamp {
3212                *b = SloBucket {
3213                    start_ms: stamp,
3214                    ..SloBucket::default()
3215                };
3216            }
3217            b.total += 1;
3218            if error {
3219                b.errors += 1;
3220            }
3221            if over_latency {
3222                b.over_latency += 1;
3223            }
3224        }
3225    }
3226
3227    /// Sum a window ring over the buckets whose start is within the
3228    /// window ending at `now_ms` (whole buckets; a partially-expired
3229    /// edge bucket is included — the 15 s granularity is the
3230    /// documented precision).
3231    fn sums(&self, ring: usize, now_ms: i64) -> (u64, u64, u64) {
3232        let cutoff = now_ms - SLO_WINDOWS[ring].0;
3233        let mut total = 0u64;
3234        let mut errors = 0u64;
3235        let mut over = 0u64;
3236        for b in &self.rings[ring] {
3237            if b.start_ms > cutoff && b.total > 0 {
3238                total += b.total;
3239                errors += b.errors;
3240                over += b.over_latency;
3241            }
3242        }
3243        (total, errors, over)
3244    }
3245}
3246
3247/// The SLO collector: owns the per-route window state and the two
3248/// output families, refreshing the gauges from the windows when
3249/// Prometheus gathers (scrape time — see the section docs).
3250struct SloState {
3251    routes: std::sync::RwLock<std::collections::HashMap<String, SloRouteState>>,
3252    /// PERF: `record` runs on EVERY completed request, but most fleets
3253    /// configure no route SLOs — the empty-map case still paid a
3254    /// `RwLock::write` (exclusive across every worker thread) per
3255    /// request. This flag gates that lock away entirely; it is
3256    /// maintained under the same write guard as the map swap, so
3257    /// `false` implies the map is empty and `record`'s `get_mut` would
3258    /// have missed anyway. A record racing a generation swap can slip
3259    /// one sample into the outgoing map or skip one in the incoming
3260    /// map — the same tolerance the pre-existing swap race already
3261    /// had, and far below one window's noise floor.
3262    configured: std::sync::atomic::AtomicBool,
3263    burn_rate: prometheus::GaugeVec,
3264    target: prometheus::GaugeVec,
3265}
3266
3267impl SloState {
3268    fn new() -> Self {
3269        let burn_rate = prometheus::GaugeVec::new(
3270            prometheus::Opts::new(
3271                "dwara_slo_burn_rate",
3272                "Error-budget consumption rate per route SLO objective \
3273                 (DW-052): (bad fraction over the window) / (1 - target). \
3274                 1.0 consumes the budget at exactly the allowed rate; \
3275                 14.4 over 1h is the classic fast-burn page. window: 5m or \
3276                 1h (process-local sliding windows — they start empty at \
3277                 boot). objective: availability (bad = gateway-answered \
3278                 5xx) or latency (bad = duration over the configured \
3279                 threshold).",
3280            ),
3281            &["route", "objective", "window"],
3282        )
3283        .expect("valid metric definition");
3284        let target = prometheus::GaugeVec::new(
3285            prometheus::Opts::new(
3286                "dwara_slo_target",
3287                "The configured target per route SLO objective (DW-052), as \
3288                 a fraction: availability or latency-target share.",
3289            ),
3290            &["route", "objective"],
3291        )
3292        .expect("valid metric definition");
3293        SloState {
3294            routes: std::sync::RwLock::new(std::collections::HashMap::new()),
3295            configured: std::sync::atomic::AtomicBool::new(false),
3296            burn_rate,
3297            target,
3298        }
3299    }
3300
3301    /// Swap the configured route set (dataplane refresh). Routes that
3302    /// vanish from the config lose their gauge children so a removed
3303    /// SLO stops exporting stale series.
3304    fn set_routes(&self, slos: Vec<(String, SloTargets)>) {
3305        let mut routes = self.routes.write().expect("slo state lock");
3306        let names: std::collections::HashSet<String> =
3307            slos.iter().map(|(n, _)| n.clone()).collect();
3308        for gone in routes.keys().filter(|n| !names.contains(*n)) {
3309            let name = gone.as_str();
3310            let objectives: &[&str] = if routes[gone].targets.latency_threshold_ms.is_some() {
3311                &["availability", "latency"]
3312            } else {
3313                &["availability"]
3314            };
3315            for objective in objectives {
3316                for window in ["5m", "1h"] {
3317                    let _ = self
3318                        .burn_rate
3319                        .remove_label_values(&[name, objective, window]);
3320                }
3321                let _ = self.target.remove_label_values(&[name, objective]);
3322            }
3323        }
3324        routes.clear();
3325        for (name, targets) in slos {
3326            routes.insert(name, SloRouteState::new(targets));
3327        }
3328        // Maintained under the write guard so a `record` in flight sees
3329        // either the fully swapped map with the flag set, or the gate
3330        // closed and no lock at all (see the field docs).
3331        self.configured
3332            .store(!routes.is_empty(), std::sync::atomic::Ordering::Release);
3333    }
3334
3335    /// Record one completed request's outcome for a configured route
3336    /// (no-op for routes without an SLO — the common case).
3337    fn record(&self, route: &str, status: u16, duration_ms: f64, now_ms: i64) {
3338        if !self.configured.load(std::sync::atomic::Ordering::Acquire) {
3339            return;
3340        }
3341        if let Some(state) = self.routes.write().expect("slo state lock").get_mut(route) {
3342            let error = status >= 500;
3343            let over_latency = state
3344                .targets
3345                .latency_threshold_ms
3346                .is_some_and(|t| duration_ms > t);
3347            state.record(now_ms, error, over_latency);
3348        }
3349    }
3350
3351    /// Refresh the gauge children from the current windows (scrape
3352    /// time). No traffic in a window => burn 0.0 (no consumption), by
3353    /// explicit decision: NaN would break alerting comparisons.
3354    fn refresh(&self, now_ms: i64) {
3355        let routes = self.routes.read().expect("slo state lock");
3356        for (route_name, state) in routes.iter() {
3357            let name = route_name.as_str();
3358            let allowed_avail = 1.0 - state.targets.availability;
3359            for (ring, (_, window)) in SLO_WINDOWS.iter().enumerate() {
3360                let (total, errors, over) = state.sums(ring, now_ms);
3361                let burn = if total > 0 && allowed_avail > 0.0 {
3362                    (errors as f64 / total as f64) / allowed_avail
3363                } else {
3364                    0.0
3365                };
3366                self.burn_rate
3367                    .with_label_values(&[name, "availability", window])
3368                    .set(burn);
3369                if state.targets.latency_threshold_ms.is_some() {
3370                    let allowed_lat = 1.0 - state.targets.latency_target;
3371                    let burn = if total > 0 && allowed_lat > 0.0 {
3372                        (over as f64 / total as f64) / allowed_lat
3373                    } else {
3374                        0.0
3375                    };
3376                    self.burn_rate
3377                        .with_label_values(&[name, "latency", window])
3378                        .set(burn);
3379                }
3380            }
3381            self.target
3382                .with_label_values(&[name, "availability"])
3383                .set(state.targets.availability);
3384            if state.targets.latency_threshold_ms.is_some() {
3385                self.target
3386                    .with_label_values(&[name, "latency"])
3387                    .set(state.targets.latency_target);
3388            }
3389        }
3390    }
3391}
3392
3393impl prometheus::core::Collector for SloState {
3394    fn desc(&self) -> Vec<&prometheus::core::Desc> {
3395        let mut d = self.burn_rate.desc();
3396        d.extend(self.target.desc());
3397        d
3398    }
3399
3400    fn collect(&self) -> Vec<prometheus::proto::MetricFamily> {
3401        let now = unix_now_ms();
3402        self.refresh(now);
3403        let mut families = self.burn_rate.collect();
3404        families.extend(self.target.collect());
3405        // A family with zero children (no route has an SLO — the
3406        // default) would make the text encoder PANIC on "no metrics";
3407        // emit nothing instead (an unconfigured feature exports no
3408        // series, exactly like a family that was never registered).
3409        families.retain(|f| !f.get_metric().is_empty());
3410        families
3411    }
3412}
3413
3414/// Registration handle: shares the live [`SloState`] so the scrape
3415/// reads the same windows the completion path writes (a cloned
3416/// GaugeVec would share only the metric handles, not the state).
3417struct SloCollectorHandle(std::sync::Arc<SloState>);
3418
3419impl prometheus::core::Collector for SloCollectorHandle {
3420    fn desc(&self) -> Vec<&prometheus::core::Desc> {
3421        prometheus::core::Collector::desc(&*self.0)
3422    }
3423    fn collect(&self) -> Vec<prometheus::proto::MetricFamily> {
3424        prometheus::core::Collector::collect(&*self.0)
3425    }
3426}
3427
3428/// Wall-clock ms since the Unix epoch (the SLO windows' time domain).
3429fn unix_now_ms() -> i64 {
3430    std::time::SystemTime::now()
3431        .duration_since(std::time::UNIX_EPOCH)
3432        .map(|d| d.as_millis() as i64)
3433        .unwrap_or(0)
3434}
3435
3436impl Observability {
3437    /// DW-052: swap the configured per-route SLO set. Called by the
3438    /// dataplane on every generation refresh; routes without an `slo`
3439    /// block record nothing (the completion-path call is a no-op for
3440    /// them).
3441    pub fn set_route_slos(&self, slos: Vec<(String, SloTargets)>) {
3442        self.slo.set_routes(slos);
3443    }
3444
3445    /// DW-052: record one completed request against its route's SLO
3446    /// windows (request-completion path; no-op when unconfigured).
3447    pub fn record_slo(&self, route: &str, status: u16, duration_ms: f64) {
3448        self.slo.record(route, status, duration_ms, unix_now_ms());
3449    }
3450
3451    /// Deterministic-clock twin of [`Self::record_slo`] for the unit
3452    /// tests (window math is time-driven; the real clock cannot make
3453    /// "the 5m window expired" deterministic).
3454    #[doc(hidden)]
3455    pub fn record_slo_at(&self, route: &str, status: u16, duration_ms: f64, now_ms: i64) {
3456        self.slo.record(route, status, duration_ms, now_ms);
3457    }
3458
3459    /// DW-052: scrape-time refresh + gather of the SLO families only
3460    /// (the full render is [`Self::render`]; this is the tests' window
3461    /// into the collector's output).
3462    #[doc(hidden)]
3463    pub fn render_slo(&self, now_ms: i64) -> String {
3464        use prometheus::core::Collector as _;
3465        self.slo.refresh(now_ms);
3466        let mut families = self.slo.burn_rate.collect();
3467        families.extend(self.slo.target.collect());
3468        families.retain(|f| !f.get_metric().is_empty());
3469        let mut buf = Vec::new();
3470        TextEncoder::new()
3471            .encode(&families, &mut buf)
3472            .expect("text encoding of gathered families cannot fail");
3473        String::from_utf8(buf).expect("prometheus text format is ASCII")
3474    }
3475
3476    /// Count one L4 connection (DW-103) in
3477    /// `dwara_l4_connections_total{listener,protocol}`. `listener` is
3478    /// the config-declared listener name (config-bounded); `protocol`
3479    /// is one of the closed set `tcp`, `udp`. Counted once per accepted
3480    /// L4 connection (before the splice is established).
3481    pub fn record_l4_connection(&self, listener: &str, protocol: &str) {
3482        self.l4_connections_total
3483            .with_label_values(&[listener, protocol])
3484            .inc();
3485    }
3486
3487    /// Record an L4 connection duration (DW-103) in
3488    /// `dwara_l4_connection_duration_seconds{listener}`. `listener` is
3489    /// the config-declared listener name (config-bounded label). Call
3490    /// once per connection (forwarded and closed alike).
3491    pub fn record_l4_connection_duration(&self, listener: &str, seconds: f64) {
3492        self.l4_connection_duration_seconds
3493            .with_label_values(&[listener])
3494            .observe(seconds);
3495    }
3496}