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}