dwara_core/analytics/
mod.rs

1//! Analytics bounded context (DW-043): the embedded analytics store.
2//!
3//! Observability answers "is it healthy right now"; analytics answers
4//! "what happened over time, to whom, and why". This domain owns the
5//! DURABLE side of that question: a local SQLite database (separate
6//! file from the state store, DW-018 — different lifecycle, different
7//! write pattern, independent bounded-disk story) holding raw access
8//! records for a short retention window and additive rollup tables at
9//! 1m/5m/1h/1d granularities with per-granularity retention, plus the
10//! query surface over them (dashboard series, Top-N, structured
11//! query) served by the admin API, and the scheduled usage-report
12//! exports (DW-120, [`exports`]) that turn the same rollups into
13//! durable per-consumer statements.
14//!
15//! # Write path (never blocks the dataplane)
16//!
17//! The dataplane's `DataPlane` (see `dataplane::proxy`) holds an
18//! [`EmbeddedAnalytics`] (set once at startup when the config carries
19//! an `analytics` block) and calls [`EmbeddedAnalytics::record`] at
20//! request completion — a `try_send` onto a bounded channel. When the
21//! channel is full the record is DROPPED (counted, logged throttled):
22//! the analytics pipeline must never slow or block a request, the same
23//! fire-and-forget posture DW-121's record stream
24//! (`events::stream`, a sibling pipeline that ships each record to an
25//! external sink instead of the local store) holds. A background
26//! writer drains the channel in batched transactions.
27//!
28//! # The sink seam
29//!
30//! The M1 extension contract
31//! `extensions::analytics::AnalyticsSink` is the OSS/Ent seam (feature
32//! analysis 11.4): the embedded store implements it (over the richer
33//! per-request fields DW-043 added to `extensions::analytics::Event`).
34//! DW-121's raw-record firehose is NOT an implementation of this
35//! contract — it streams the completion-time access record (which
36//! carries `request_id` and the redacted path the extension event
37//! deliberately omits) through its own sink seam in `events::stream`.
38//! The federated analytics pipeline (DW-095, SCALE-07 #186) IS an
39//! implementation of this contract: `cp_dp::analytics::FederatedAnalyticsSink`
40//! batches events on the edge and ships them to the controller over
41//! gRPC; the controller's `EmbeddedCollector` forwards them to the
42//! aggregate `EmbeddedAnalytics` store, tagging each event with the
43//! originating `edge_id` for fleet-wide query filtering.
44//!
45//! # Custom dimensions
46//!
47//! Config-declared dimensions extracted from request HEADERS at
48//! completion-time capture (e.g. `x-plan` -> dimension `plan`): they
49//! ride the [`AccessRecord`] into
50//! raw rows (a JSON object column) and aggregate into their own
51//! narrow rollup table. They are analytics-only — deliberately NOT
52//! added to the access log, whose field list is redacted by
53//! construction and stays that way.
54
55pub mod exports;
56pub mod insights;
57pub mod partition;
58pub mod query;
59pub mod rollup;
60pub mod schema;
61pub mod sketch;
62
63use std::collections::HashMap;
64use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicUsize, Ordering};
65use std::sync::{Arc, Mutex, RwLock};
66
67use tokio::sync::mpsc;
68
69use crate::observability::AccessRecord;
70
71/// Channel capacity between the request path and the writer. Sized so
72/// a burst of completions (or a briefly stalled writer transaction)
73/// queues rather than drops; drops are counted and throttled-logged.
74const CHANNEL_CAP: usize = 4096;
75
76/// REL-10 (#221): sampled degradation parameters, resolved from
77/// `AnalyticsConfig::sampled_degradation` at store build time. Stored
78/// inline in `EmbeddedAnalytics` so the offer path reads them without
79/// indirection.
80#[derive(Debug, Clone, Copy)]
81pub struct SampledDegradation {
82    high_watermark: f64,
83    low_watermark: f64,
84    keep_rate: f64,
85}
86
87impl SampledDegradation {
88    /// Build from the config-level struct.
89    pub fn from_config(high_watermark: f64, low_watermark: f64, keep_rate: f64) -> Self {
90        Self {
91            high_watermark,
92            low_watermark,
93            keep_rate,
94        }
95    }
96}
97
98/// Writer batch flush bound (records) — with the flush tick, whichever
99/// comes first.
100const BATCH_MAX: usize = 1024;
101
102/// How often the rollup/retention worker runs. Rollups are cheap
103/// cursor-guarded SQL; 30 s keeps even a 1-minute window's staleness
104/// under a minute end-to-end (plus the completion grace below).
105const ROLLUP_INTERVAL_MS: u64 = 30_000;
106
107/// Completion GRACE: a fine window is rolled only once its end is at
108/// least this far in the past — headroom for the writer's flush tick
109/// and batch latency, so a straggler record still lands in the window
110/// it belongs to. Records later than the grace are documented as
111/// rollup-lost (they remain in `raw` until raw retention expires).
112pub const ROLLUP_GRACE_MS: i64 = 60_000;
113
114/// Per-granularity retention defaults (ms) — the single definition
115/// lives in [`crate::config::ANALYTICS_DEFAULT_RETENTION_MS`] (the
116/// lowest consuming domain); re-exported here for the module's
117/// readers.
118pub use crate::config::ANALYTICS_DEFAULT_RETENTION_MS as DEFAULT_RETENTION_MS;
119
120/// One raw record as stored (the owned, completion-time copy handed
121/// across the channel).
122struct RawRecord {
123    ts_ms: i64,
124    request_id: String,
125    correlation_id: String,
126    listener: String,
127    route: String,
128    consumer: String,
129    upstream: String,
130    method: String,
131    status: u16,
132    status_class: String,
133    duration_ms: f64,
134    attempts: u32,
135    rate_limited: bool,
136    broken: bool,
137    shed: bool,
138    /// PERF-12 (#209): inbound request body bytes (declared
139    /// Content-Length, 0 for chunked).
140    bytes_in: i64,
141    /// PERF-12 (#209): outbound response body bytes (frame-counted
142    /// as the body streamed to the client, no buffering).
143    bytes_out: i64,
144    dims: String,
145    /// DW-102: redacted request headers (opt-in, JSON object string),
146    /// or `None` when replay capture is off or headers are not
147    /// captured. Scrubbed via the existing PII redaction patterns
148    /// before the record is offered.
149    request_headers_redacted: Option<String>,
150    /// DW-102: the authenticated consumer name (opt-in), or `None`
151    /// when replay capture is off or the request was anonymous.
152    auth_identity: Option<String>,
153}
154
155/// The custom-dimensions JSON column for a (name, value) pair list.
156fn dims_json(pairs: &[(String, String)]) -> String {
157    if pairs.is_empty() {
158        return "{}".to_string();
159    }
160    let map: serde_json::Map<String, serde_json::Value> = pairs
161        .iter()
162        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
163        .collect();
164    serde_json::Value::Object(map).to_string()
165}
166
167/// One AI spend record (DW-079): the per-request spend dimensions
168/// written to the `ai_spend` table. A PLAIN DTO — deliberately not
169/// importing `ai::types::Usage` (the analytics domain may not import
170/// the `ai` domain; see `scripts/check_deps.py`). The dataplane
171/// converts from `ai::types::Usage` at the call site.
172#[derive(Debug, Clone)]
173pub struct AiSpendRecord {
174    /// Wall-clock ms since the Unix epoch.
175    pub ts_ms: i64,
176    /// The authenticated consumer name (or "anonymous").
177    pub consumer: String,
178    /// The consumer type (DW-113): `"user"` or `"agent"`. Defaults to
179    /// `"user"` for pre-DW-113 records and anonymous traffic.
180    pub consumer_type: String,
181    /// The policy-scoped team key (the policy name a `scope: policy`
182    /// budget uses), or empty when no team budget binds the request.
183    pub team: String,
184    /// The serving provider name (`ai.providers[].name`).
185    pub provider: String,
186    /// The provider's own model identifier (the pricing key).
187    pub model: String,
188    /// The canary version label (DW-076), or empty for non-canary.
189    pub version: String,
190    /// Provider-reported prompt (input) tokens.
191    pub prompt_tokens: u64,
192    /// Provider-reported completion (output) tokens.
193    pub completion_tokens: u64,
194    /// Total tokens (prompt + completion, or the provider's own total).
195    pub total_tokens: u64,
196    /// Priced cost in integer micro-USD (usage x pricing table).
197    pub cost_micros: u64,
198}
199
200/// One AI governance audit event (DW-084): the per-check record
201/// written to the `ai_governance_events` table. A PLAIN DTO —
202/// deliberately not importing `ai::governance` (the analytics domain
203/// may not import the `ai` domain; see `scripts/check_deps.py`). The
204/// dataplane converts from `ai::governance::GovernanceVerdict` at the
205/// call site.
206#[derive(Debug, Clone)]
207pub struct AiGovernanceEvent {
208    /// Wall-clock ms since the Unix epoch.
209    pub ts_ms: i64,
210    /// The authenticated consumer name (or "anonymous").
211    pub consumer: String,
212    /// The denying policy (team) name for a denial, or the FIRST
213    /// binding allowlist's policy name for an allow (empty when no
214    /// allowlist binds the request).
215    pub team: String,
216    /// The client-facing model alias the request named.
217    pub model: String,
218    /// `allow` or `deny`.
219    pub verdict: String,
220    /// A short reason string (the denial cause; empty for an allow).
221    pub reason: String,
222}
223
224/// One AI prompt/response log record (DW-081): the per-request
225/// capture written to the `ai_prompt_logs` table. A PLAIN DTO —
226/// deliberately not importing `ai::types` (the analytics domain may
227/// not import the `ai` domain; see `scripts/check_deps.py`). The
228/// dataplane serializes and redacts the `ChatRequest`/`ChatResponse`
229/// at the call site, passing the redacted JSON strings here. Capture
230/// is opt-in (privacy-first); the redaction pass runs BEFORE the
231/// record is offered.
232#[derive(Debug, Clone)]
233pub struct AiPromptLogRecord {
234    /// Wall-clock ms since the Unix epoch.
235    pub ts_ms: i64,
236    /// The request id (correlation handle).
237    pub request_id: String,
238    /// The authenticated consumer name (or "anonymous").
239    pub consumer: String,
240    /// The route name that served the request.
241    pub route: String,
242    /// The serving provider name.
243    pub provider: String,
244    /// The provider's own model identifier.
245    pub model: String,
246    /// The canary version label, or empty for non-canary.
247    pub version: String,
248    /// The REDACTED prompt as JSON (the serialized ChatRequest after
249    /// redaction).
250    pub prompt_json: String,
251    /// The REDACTED response as JSON (the serialized ChatResponse
252    /// after redaction, or `{"streamed": true}` for streaming
253    /// captures where the full content is not reassembled).
254    pub response_json: String,
255    /// Whether the request was streaming.
256    pub stream: bool,
257}
258
259/// One AI experiment assignment record (DW-086): the per-request
260/// record written to the `ai_experiment_assignments` table when a
261/// request is served by an A/B test alias. A PLAIN DTO — deliberately
262/// not importing `ai::experiments` (the analytics domain may not
263/// import the `ai` domain; see `scripts/check_deps.py`).
264#[derive(Debug, Clone)]
265pub struct AiExperimentAssignment {
266    /// Wall-clock ms since the Unix epoch.
267    pub ts_ms: i64,
268    /// The request id (correlation handle).
269    pub request_id: String,
270    /// The experiment (A/B test) name.
271    pub experiment: String,
272    /// The selected variant name.
273    pub variant: String,
274    /// The model alias the variant routes to.
275    pub model: String,
276    /// The authenticated consumer name (or "anonymous").
277    pub consumer: String,
278}
279
280/// One AI eval result record (DW-086): the per-case record written to
281/// the `ai_eval_results` table when an eval is run via the admin API.
282/// A PLAIN DTO — the admin endpoint constructs it from the eval
283/// runner's output.
284#[derive(Debug, Clone)]
285pub struct AiEvalResultRecord {
286    /// Wall-clock ms since the Unix epoch.
287    pub ts_ms: i64,
288    /// The eval name (from config).
289    pub eval_name: String,
290    /// The model alias the eval ran against.
291    pub model: String,
292    /// The variant name (when running an A/B test's variants), or
293    /// empty.
294    pub variant: String,
295    /// The prompt version reference (`prompt_name/version_name`), or
296    /// empty.
297    pub prompt_version: String,
298    /// The case index in the golden set.
299    pub case_index: usize,
300    /// The input prompt.
301    pub input: String,
302    /// The expected output.
303    pub expected: String,
304    /// The actual output from the provider.
305    pub actual: String,
306    /// Whether the case passed (scorer matched).
307    pub passed: bool,
308    /// The scorer name (`exact_match`, `contains`, `regex`).
309    pub scorer: String,
310    /// The latency of the provider call in milliseconds.
311    pub latency_ms: f64,
312}
313
314/// One AI feedback record (DW-086): the per-feedback record written
315/// to the `ai_feedback` table when feedback is ingested via the admin
316/// API.
317#[derive(Debug, Clone)]
318pub struct AiFeedbackRecord {
319    /// Wall-clock ms since the Unix epoch.
320    pub ts_ms: i64,
321    /// The request id the feedback refers to.
322    pub request_id: String,
323    /// The feedback label (e.g. `good`, `bad`, `thumbs_up`).
324    pub label: String,
325    /// An optional free-form comment.
326    pub comment: String,
327    /// The authenticated consumer name (or "anonymous").
328    pub consumer: String,
329    /// The model alias the feedback refers to (optional).
330    pub model: String,
331}
332
333/// One MCP tool call record (DW-087): the per-call record written to
334/// the `mcp_tool_calls` table when a tool call is proxied through the
335/// MCP gateway. A PLAIN DTO — deliberately not importing `ai::mcp`
336/// (the analytics domain may not import the `ai` domain; see
337/// `scripts/check_deps.py`). The dataplane constructs it at the call
338/// site.
339#[derive(Debug, Clone)]
340pub struct McpToolCallRecord {
341    /// Wall-clock ms since the Unix epoch.
342    pub ts_ms: i64,
343    /// The request id (correlation handle).
344    pub request_id: String,
345    /// The MCP session id (correlates calls within one agent session).
346    pub session_id: String,
347    /// The authenticated consumer name (or "anonymous").
348    pub consumer: String,
349    /// The consumer type (DW-113): `"user"` or `"agent"`. Defaults to
350    /// `"user"` for pre-DW-113 records and anonymous traffic.
351    pub consumer_type: String,
352    /// The tool name that was called.
353    pub tool_name: String,
354    /// Whether the call was authorized (passed the per-tool authz
355    /// check). `false` for denied calls.
356    pub allowed: bool,
357    /// The call duration in milliseconds (from the authz check
358    /// through the upstream response).
359    pub duration_ms: f64,
360    /// An optional error code (e.g. `"unauthorized"`,
361    /// `"upstream_error"`, `"timeout"`). `None` for successful calls.
362    pub error_code: Option<String>,
363    /// The call status: `success`, `error`, or `denied`.
364    pub status: String,
365}
366
367impl RawRecord {
368    /// The extension-event shape of the same record (the
369    /// `extensions::analytics::AnalyticsSink` contract's input type).
370    fn from_event(event: &crate::extensions::analytics::Event) -> Self {
371        // SCALE-07 (#186): include edge_id as a dimension so fleet-wide
372        // queries can filter by edge without a schema migration. The
373        // dims column is a JSON object string; the edge_id dimension
374        // is queryable alongside the config-declared custom dimensions.
375        let mut attrs = event.attributes.clone();
376        if let Some(ref edge_id) = event.edge_id {
377            attrs.push(("edge_id".to_string(), edge_id.clone()));
378        }
379        RawRecord {
380            ts_ms: event.timestamp_ms as i64,
381            request_id: String::new(),
382            correlation_id: String::new(),
383            listener: event.listener.clone().unwrap_or_default(),
384            route: event.route.clone().unwrap_or_else(|| "unrouted".into()),
385            consumer: event.consumer.clone().unwrap_or_else(|| "anonymous".into()),
386            // The extension event carries the ENDPOINT; the raw table's
387            // `upstream` column is the queries' upstream axis — the
388            // event shape has no separate upstream field, so
389            // endpoint-addressed events record the endpoint there.
390            upstream: event.endpoint.clone().unwrap_or_default(),
391            method: event.method.clone().unwrap_or_default(),
392            status: event.status.unwrap_or(0),
393            status_class: event
394                .status
395                .map(|s| format!("{}xx", s / 100))
396                .unwrap_or_else(|| "0xx".into()),
397            duration_ms: event.duration_ms.unwrap_or(0.0),
398            attempts: event.attempts.unwrap_or(0),
399            rate_limited: event.rate_limited,
400            broken: event.broken,
401            shed: event.shed,
402            bytes_in: 0_i64,
403            bytes_out: 0_i64,
404            dims: dims_json(&attrs),
405            request_headers_redacted: None,
406            auth_identity: None,
407        }
408    }
409
410    fn from(rec: &AccessRecord, now_ms: i64) -> Self {
411        RawRecord {
412            ts_ms: now_ms,
413            request_id: rec.request_id.clone(),
414            correlation_id: rec.correlation_id.clone(),
415            listener: rec.listener.clone(),
416            route: rec.route.clone(),
417            consumer: rec.consumer.clone(),
418            upstream: rec.upstream.clone().unwrap_or_default(),
419            method: rec.method.clone(),
420            status: rec.status,
421            status_class: crate::observability::status_class(rec.status).to_owned(),
422            duration_ms: rec.duration_ms,
423            attempts: rec.attempts,
424            rate_limited: rec.rate_limited,
425            broken: rec.broken,
426            shed: rec.shed,
427            bytes_in: rec.bytes_in as i64,
428            bytes_out: rec.bytes_out.load(std::sync::atomic::Ordering::Relaxed) as i64,
429            dims: dims_json(&rec.custom),
430            request_headers_redacted: None,
431            auth_identity: None,
432        }
433    }
434}
435
436/// Wall-clock ms since the Unix epoch (the analytics time domain).
437fn now_ms() -> i64 {
438    std::time::SystemTime::now()
439        .duration_since(std::time::UNIX_EPOCH)
440        .map(|d| d.as_millis() as i64)
441        .unwrap_or(0)
442}
443
444/// One per-route sketch within the live rolling window (DW-092,
445/// PERF-09 #242). Updated in place under atomics (counts) and a short
446/// mutex over a DDSketch (bounded-memory, relative-error quantile
447/// sketch). The route key is held by the parent map, not here.
448struct RouteSketch {
449    /// Total requests in the current window.
450    requests: AtomicU64,
451    /// Total errors (status >= 500) in the current window.
452    errors: AtomicU64,
453    /// DDSketch quantile sketch (PERF-09, #242): bounded-memory,
454    /// relative-error-guaranteed percentile estimation. Replaces the
455    /// previous ring-buffer + nearest-rank approach. The sketch is
456    /// updated under a short mutex; the DDSketch's bucket count is
457    /// bounded (default 8192) regardless of traffic volume.
458    latency: Mutex<sketch::DDSketch>,
459}
460
461impl RouteSketch {
462    fn new() -> Self {
463        RouteSketch {
464            requests: AtomicU64::new(0),
465            errors: AtomicU64::new(0),
466            latency: Mutex::new(sketch::DDSketch::new()),
467        }
468    }
469}
470
471/// One route's snapshot from the current live window (DW-092).
472#[derive(Debug, Clone, serde::Serialize)]
473pub struct LiveRouteSnapshot {
474    /// The route name.
475    pub route: String,
476    /// Total requests in the current window.
477    pub requests: u64,
478    /// Total errors (status >= 500) in the current window.
479    pub errors: u64,
480    /// The error rate as a fraction in [0, 1].
481    pub error_rate: f64,
482    /// The p50 latency in milliseconds.
483    pub p50_ms: f64,
484    /// The p95 latency in milliseconds.
485    pub p95_ms: f64,
486    /// The p99 latency in milliseconds.
487    pub p99_ms: f64,
488    /// The average (mean) latency in milliseconds.
489    pub avg_ms: f64,
490}
491
492/// A snapshot of the current live window across all routes (DW-092).
493/// Returned by [`EmbeddedAnalytics::live_snapshot`].
494#[derive(Debug, Clone, serde::Serialize)]
495pub struct LiveSnapshot {
496    /// The window's start time (wall-clock ms since the Unix epoch).
497    pub window_start_ms: u64,
498    /// The window's end time (start + window_size_ms).
499    pub window_end_ms: u64,
500    /// Per-route snapshots, sorted by route name for stable output.
501    pub routes: Vec<LiveRouteSnapshot>,
502}
503
504/// Live in-process sketches (DW-092): sub-second-freshness per-route
505/// rolling window with counts, errors, and capped latency samples.
506/// Maintained inside [`EmbeddedAnalytics`] and updated synchronously
507/// on every `record()` call — never blocks the dataplane (atomics for
508/// counts, a short mutex over a capped vector for samples). When the
509/// window expires, the completed window's aggregates are handed to
510/// the insights engine (if attached) for forecasting and anomaly
511/// detection.
512pub struct LiveSketches {
513    /// Per-route current-window data, behind a RwLock so the snapshot
514    /// read path (admin) does not block the record write path
515    /// (dataplane).
516    routes: RwLock<HashMap<String, RouteSketch>>,
517    /// The current window's start time (wall-clock ms). Atomic so the
518    /// rotation check on the hot path is lock-free.
519    window_start_ms: AtomicU64,
520    /// The window size in milliseconds (= the freshness target).
521    window_size_ms: u64,
522    /// The insights engine fed on window rotation (None when insights
523    /// are disabled).
524    insights: RwLock<Option<Arc<insights::InsightsEngine>>>,
525}
526
527impl LiveSketches {
528    /// Construct a new live-sketches store with the given window size.
529    pub fn new(window_size_ms: u64) -> Arc<Self> {
530        Arc::new(LiveSketches {
531            routes: RwLock::new(HashMap::new()),
532            window_start_ms: AtomicU64::new(0),
533            window_size_ms,
534            insights: RwLock::new(None),
535        })
536    }
537
538    /// Attach the insights engine so window rotations feed it. Called
539    /// once during [`EmbeddedAnalytics::open`] wiring.
540    pub fn set_insights(&self, engine: Arc<insights::InsightsEngine>) {
541        *self.insights.write().unwrap() = Some(engine);
542    }
543
544    /// The current window's start time (wall-clock ms).
545    pub fn window_start(&self) -> u64 {
546        self.window_start_ms.load(Ordering::Relaxed)
547    }
548
549    /// Record one request completion into the current window. Rotates
550    /// the window (handing the completed aggregates to the insights
551    /// engine) when `now_ms` has passed the window boundary.
552    pub fn record(&self, route: &str, status: u16, duration_ms: f64, now_ms: u64) {
553        // Rotate the window if it has expired. The rotation is under
554        // the write lock so a concurrent snapshot sees a consistent
555        // window boundary.
556        let start = self.window_start_ms.load(Ordering::Relaxed);
557        if start == 0 || now_ms >= start + self.window_size_ms {
558            self.rotate(now_ms);
559        }
560        // Look up or create the route sketch under a short write lock.
561        // The map is keyed by route name; a missing entry is inserted.
562        let map = self.routes.read().unwrap();
563        if let Some(sketch) = map.get(route) {
564            sketch.requests.fetch_add(1, Ordering::Relaxed);
565            if status >= 500 {
566                sketch.errors.fetch_add(1, Ordering::Relaxed);
567            }
568            sketch.latency.lock().unwrap().add(duration_ms);
569            return;
570        }
571        drop(map);
572        let mut map = self.routes.write().unwrap();
573        let sketch = map
574            .entry(route.to_string())
575            .or_insert_with(RouteSketch::new);
576        sketch.requests.fetch_add(1, Ordering::Relaxed);
577        if status >= 500 {
578            sketch.errors.fetch_add(1, Ordering::Relaxed);
579        }
580        sketch.latency.lock().unwrap().add(duration_ms);
581    }
582
583    /// Rotate the window: snapshot the current aggregates, hand them
584    /// to the insights engine (if attached), then reset the per-route
585    /// sketches for the new window. The new window starts at `now_ms`
586    /// (aligned to the rotation, not the expiry — a missed rotation
587    /// catches up to the present).
588    fn rotate(&self, now_ms: u64) {
589        let mut map = self.routes.write().unwrap();
590        let old_start = self.window_start_ms.swap(now_ms, Ordering::Relaxed);
591        // Feed the completed window to the insights engine (if any).
592        if old_start != 0 {
593            let mut total_requests: u64 = 0;
594            let mut total_errors: u64 = 0;
595            let mut latency_sum: f64 = 0.0;
596            let mut latency_count: u64 = 0;
597            for sketch in map.values() {
598                let req = sketch.requests.load(Ordering::Relaxed);
599                let err = sketch.errors.load(Ordering::Relaxed);
600                total_requests += req;
601                total_errors += err;
602                let mean = sketch.latency.lock().unwrap().mean();
603                latency_sum += mean * req as f64;
604                latency_count += req;
605            }
606            let avg_latency = if latency_count > 0 {
607                latency_sum / latency_count as f64
608            } else {
609                0.0
610            };
611            let window = insights::BaselineWindow {
612                ts_ms: old_start as i64,
613                requests: total_requests,
614                errors: total_errors,
615                avg_latency_ms: avg_latency,
616            };
617            if let Some(engine) = self.insights.read().unwrap().as_ref() {
618                engine.observe(window);
619            }
620        }
621        // Reset the per-route sketches for the new window. Clearing
622        // the map is simplest and avoids retaining stale routes; the
623        // first record of the new window re-creates each route's
624        // sketch.
625        map.clear();
626    }
627
628    /// Snapshot the current window's per-route aggregates. Computes
629    /// p50/p95/p99 from the retained latency samples (sort-and-pick,
630    /// O(n log n) with n capped at 1000). This is an admin-path
631    /// operation, never on the request hot path.
632    pub fn snapshot(&self, now_ms: u64) -> LiveSnapshot {
633        // Rotate if the window has expired so a snapshot reflects a
634        // current (non-stale) window.
635        let start = self.window_start_ms.load(Ordering::Relaxed);
636        if start == 0 || now_ms >= start + self.window_size_ms {
637            self.rotate(now_ms);
638        }
639        let start = self.window_start_ms.load(Ordering::Relaxed);
640        let map = self.routes.read().unwrap();
641        let mut routes: Vec<LiveRouteSnapshot> = map
642            .iter()
643            .map(|(route, sketch)| {
644                let requests = sketch.requests.load(Ordering::Relaxed);
645                let errors = sketch.errors.load(Ordering::Relaxed);
646                let (p50, p95, p99, avg) = {
647                    let sketch = sketch.latency.lock().unwrap();
648                    (
649                        sketch.quantile(0.50),
650                        sketch.quantile(0.95),
651                        sketch.quantile(0.99),
652                        sketch.mean(),
653                    )
654                };
655                LiveRouteSnapshot {
656                    route: route.clone(),
657                    requests,
658                    errors,
659                    error_rate: if requests == 0 {
660                        0.0
661                    } else {
662                        errors as f64 / requests as f64
663                    },
664                    p50_ms: p50,
665                    p95_ms: p95,
666                    p99_ms: p99,
667                    avg_ms: avg,
668                }
669            })
670            .collect();
671        routes.sort_by(|a, b| a.route.cmp(&b.route));
672        LiveSnapshot {
673            window_start_ms: start,
674            window_end_ms: start + self.window_size_ms,
675            routes,
676        }
677    }
678}
679
680/// The embedded analytics store (DW-043): SQLite file + bounded
681/// channel + background writer. Rollup/retention workers are spawned
682/// by [`EmbeddedAnalytics::spawn_workers`].
683pub struct EmbeddedAnalytics {
684    conn: Mutex<rusqlite::Connection>,
685    tx: mpsc::Sender<RawRecord>,
686    rx: Mutex<Option<mpsc::Receiver<RawRecord>>>,
687    /// DW-079: the AI spend record channel (same fire-and-forget
688    /// posture as the raw record channel — drop and count on full,
689    /// never block the request path).
690    spend_tx: mpsc::Sender<AiSpendRecord>,
691    spend_rx: Mutex<Option<mpsc::Receiver<AiSpendRecord>>>,
692    /// Records dropped on a full SPEND channel (the never-block
693    /// counter for the DW-079 path).
694    spend_dropped: AtomicU64,
695    /// DW-084: the AI governance event channel (same fire-and-forget
696    /// posture as the spend channel — drop and count on full, never
697    /// block the request path).
698    gov_tx: mpsc::Sender<AiGovernanceEvent>,
699    gov_rx: Mutex<Option<mpsc::Receiver<AiGovernanceEvent>>>,
700    /// Records dropped on a full GOVERNANCE channel (the never-block
701    /// counter for the DW-084 path).
702    gov_dropped: AtomicU64,
703    /// DW-081: the AI prompt log channel (same fire-and-forget
704    /// posture as the spend/governance channels — drop and count on
705    /// full, never block the request path).
706    prompt_log_tx: mpsc::Sender<AiPromptLogRecord>,
707    prompt_log_rx: Mutex<Option<mpsc::Receiver<AiPromptLogRecord>>>,
708    /// Records dropped on a full PROMPT_LOG channel (the never-block
709    /// counter for the DW-081 path).
710    prompt_log_dropped: AtomicU64,
711    /// DW-081: the prompt log retention window in ms. Records older
712    /// than this are deleted by the maintenance tick. 0 = no
713    /// retention sweep (the default when logging is off). Atomic so
714    /// a reload can update it without blocking the maintainer.
715    prompt_log_retention_ms: AtomicI64,
716    /// DW-086: the experiment assignment channel (same fire-and-forget
717    /// posture as the spend/governance/prompt-log channels — drop and
718    /// count on full, never block the request path).
719    exp_assign_tx: mpsc::Sender<AiExperimentAssignment>,
720    exp_assign_rx: Mutex<Option<mpsc::Receiver<AiExperimentAssignment>>>,
721    /// Records dropped on a full EXP_ASSIGN channel (the never-block
722    /// counter for the DW-086 assignment path).
723    exp_assign_dropped: AtomicU64,
724    /// DW-086: the eval result channel (same fire-and-forget posture
725    /// — the admin eval runner writes results here, and a background
726    /// writer drains them into the `ai_eval_results` table).
727    eval_result_tx: mpsc::Sender<AiEvalResultRecord>,
728    eval_result_rx: Mutex<Option<mpsc::Receiver<AiEvalResultRecord>>>,
729    /// Records dropped on a full EVAL_RESULT channel.
730    eval_result_dropped: AtomicU64,
731    /// DW-086: the feedback channel (same fire-and-forget posture —
732    /// the admin feedback endpoint writes records here, and a
733    /// background writer drains them into the `ai_feedback` table).
734    feedback_tx: mpsc::Sender<AiFeedbackRecord>,
735    feedback_rx: Mutex<Option<mpsc::Receiver<AiFeedbackRecord>>>,
736    /// Records dropped on a full FEEDBACK channel.
737    feedback_dropped: AtomicU64,
738    /// DW-087: the MCP tool call channel (same fire-and-forget
739    /// posture — drop and count on full, never block the request
740    /// path).
741    mcp_tx: mpsc::Sender<McpToolCallRecord>,
742    mcp_rx: Mutex<Option<mpsc::Receiver<McpToolCallRecord>>>,
743    /// Records dropped on a full MCP channel (the never-block
744    /// counter for the DW-087 path).
745    mcp_dropped: AtomicU64,
746    /// Retention config: [raw, 1m, 5m, 1h, 1d] in ms.
747    retention_ms: [i64; 5],
748    /// Flush tick (ms) — the writer's maximum batch latency.
749    flush_ms: u64,
750    /// Records dropped on a full channel (the never-block counter).
751    dropped: AtomicU64,
752    /// REL-10 (#221): records dropped by sampled degradation (the
753    /// probabilistic pre-full drop). Counted separately from `dropped`
754    /// so an operator can distinguish "channel was full" from
755    /// "sampling kicked in to avoid filling the channel".
756    sampled_dropped: AtomicU64,
757    /// REL-10 (#221): sampled degradation config. None = no sampling
758    /// (the pre-#221 behavior: keep everything until the channel is
759    /// full, then drop-and-count). When set, the offer path checks the
760    /// channel fill ratio and probabilistically drops records above
761    /// `high_watermark` to avoid a hard drop cliff.
762    sampled_degradation: Option<SampledDegradation>,
763    /// REL-10 (#221): in-flight record count (approximate). Incremented
764    /// on a successful `try_send` and decremented when the writer
765    /// drains a batch. The tokio `mpsc::Sender` does not expose a
766    /// len/capacity query, so we track it ourselves to compute the fill
767    /// ratio for sampled degradation. Approximate under concurrency
768    /// (the offer path reads it without a lock); the sampling decision
769    /// is probabilistic anyway, so a stale read only shifts the
770    /// threshold by one record.
771    in_flight: AtomicUsize,
772    /// REL-10 (#221): hysteresis flag — true when sampling is active.
773    /// Set when the fill ratio crosses `high_watermark`; cleared when
774    /// it drops below `low_watermark`. Prevents flapping around the
775    /// threshold.
776    sampling_active: AtomicBool,
777    /// DW-092: live in-process sketches (sub-second-freshness per-route
778    /// rolling window). None when `analytics.live_sketches` is absent
779    /// or disabled — the `GET /analytics/live` endpoint answers
780    /// `analytics_not_configured` in that case. Updated synchronously
781    /// in [`EmbeddedAnalytics::record`].
782    live: Mutex<Option<Arc<LiveSketches>>>,
783    /// DW-092: the ML traffic insights engine (EWMA forecasting +
784    /// seasonal-baseline anomaly detection). None when
785    /// `analytics.insights` is absent or both features are disabled —
786    /// the `GET /analytics/forecast` and `GET /analytics/anomalies`
787    /// endpoints answer `analytics_not_configured` in that case. Fed
788    /// by the live-sketch window rotation.
789    insights: Mutex<Option<Arc<insights::InsightsEngine>>>,
790}
791
792impl EmbeddedAnalytics {
793    /// Open (or create) the analytics database at `path`, apply
794    /// migrations, and return the store with its channel wired. The
795    /// writer is NOT started here — see
796    /// [`EmbeddedAnalytics::spawn_workers`]. `prompt_log_retention_ms`
797    /// is the DW-081 prompt log retention window (0 = no sweep).
798    pub fn open(
799        path: &str,
800        retention_ms: [i64; 5],
801        flush_ms: u64,
802        prompt_log_retention_ms: i64,
803        sampled_degradation: Option<SampledDegradation>,
804    ) -> rusqlite::Result<Arc<Self>> {
805        let conn = rusqlite::Connection::open(path)?;
806        conn.pragma_update(None, "journal_mode", "WAL")?;
807        conn.pragma_update(None, "synchronous", "NORMAL")?;
808        conn.pragma_update(None, "auto_vacuum", "INCREMENTAL")?;
809        conn.pragma_update(None, "busy_timeout", 5000)?;
810        schema::migrate(&conn)?;
811        // SCALE-09 (#188): ensure the raw_all view exists (it may not
812        // if the database was created before partitioning was added).
813        if let Err(e) = partition::refresh_raw_all_view(&conn) {
814            tracing::warn!(
815                code = "analytics_raw_all_view_failed",
816                "failed to create raw_all view: {e}"
817            );
818        }
819        let (tx, rx) = mpsc::channel(CHANNEL_CAP);
820        let (spend_tx, spend_rx) = mpsc::channel(CHANNEL_CAP);
821        let (gov_tx, gov_rx) = mpsc::channel(CHANNEL_CAP);
822        let (prompt_log_tx, prompt_log_rx) = mpsc::channel(CHANNEL_CAP);
823        let (exp_assign_tx, exp_assign_rx) = mpsc::channel(CHANNEL_CAP);
824        let (eval_result_tx, eval_result_rx) = mpsc::channel(CHANNEL_CAP);
825        let (feedback_tx, feedback_rx) = mpsc::channel(CHANNEL_CAP);
826        let (mcp_tx, mcp_rx) = mpsc::channel(CHANNEL_CAP);
827        Ok(Arc::new(EmbeddedAnalytics {
828            conn: Mutex::new(conn),
829            rx: Mutex::new(Some(rx)),
830            tx,
831            spend_tx,
832            spend_rx: Mutex::new(Some(spend_rx)),
833            spend_dropped: AtomicU64::new(0),
834            gov_tx,
835            gov_rx: Mutex::new(Some(gov_rx)),
836            gov_dropped: AtomicU64::new(0),
837            prompt_log_tx,
838            prompt_log_rx: Mutex::new(Some(prompt_log_rx)),
839            prompt_log_dropped: AtomicU64::new(0),
840            prompt_log_retention_ms: AtomicI64::new(prompt_log_retention_ms),
841            exp_assign_tx,
842            exp_assign_rx: Mutex::new(Some(exp_assign_rx)),
843            exp_assign_dropped: AtomicU64::new(0),
844            eval_result_tx,
845            eval_result_rx: Mutex::new(Some(eval_result_rx)),
846            eval_result_dropped: AtomicU64::new(0),
847            feedback_tx,
848            feedback_rx: Mutex::new(Some(feedback_rx)),
849            feedback_dropped: AtomicU64::new(0),
850            mcp_tx,
851            mcp_rx: Mutex::new(Some(mcp_rx)),
852            mcp_dropped: AtomicU64::new(0),
853            retention_ms,
854            flush_ms,
855            dropped: AtomicU64::new(0),
856            sampled_dropped: AtomicU64::new(0),
857            sampled_degradation,
858            in_flight: AtomicUsize::new(0),
859            sampling_active: AtomicBool::new(false),
860            live: Mutex::new(None),
861            insights: Mutex::new(None),
862        }))
863    }
864
865    /// Spawn the writer and rollup/retention workers. Exactly one set
866    /// per store (the receiver is taken once; a second call returns an
867    /// empty set and logs). Both stop on `shutdown`; the writer drains
868    /// what is already queued first, then performs a final rollup and
869    /// retention pass so a clean restart loses nothing.
870    pub fn spawn_workers(
871        self: &Arc<Self>,
872        shutdown: tokio::sync::watch::Receiver<()>,
873    ) -> Vec<tokio::task::JoinHandle<()>> {
874        let Some(mut rx) = self.rx.lock().unwrap().take() else {
875            tracing::error!(
876                code = "analytics_workers_already_running",
877                "analytics workers already spawned for this store; ignoring"
878            );
879            return Vec::new();
880        };
881        // DW-079: take the spend receiver alongside the raw receiver —
882        // a second call returns nothing for either.
883        let spend_rx_opt = self.spend_rx.lock().unwrap().take();
884        // DW-084: take the governance receiver alongside the others.
885        let gov_rx_opt = self.gov_rx.lock().unwrap().take();
886        // DW-081: take the prompt log receiver alongside the others.
887        let prompt_log_rx_opt = self.prompt_log_rx.lock().unwrap().take();
888        // DW-086: take the experiment assignment, eval result, and
889        // feedback receivers alongside the others.
890        let exp_assign_rx_opt = self.exp_assign_rx.lock().unwrap().take();
891        let eval_result_rx_opt = self.eval_result_rx.lock().unwrap().take();
892        let feedback_rx_opt = self.feedback_rx.lock().unwrap().take();
893        // DW-087: take the MCP tool call receiver alongside the others.
894        let mcp_rx_opt = self.mcp_rx.lock().unwrap().take();
895        let mut writer_shutdown = shutdown.clone();
896        let writer = {
897            let store = Arc::clone(self);
898            tokio::spawn(async move {
899                let mut tick =
900                    tokio::time::interval(std::time::Duration::from_millis(store.flush_ms));
901                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
902                let mut batch: Vec<RawRecord> = Vec::with_capacity(BATCH_MAX);
903                loop {
904                    let drained = tokio::select! {
905                        m = rx.recv() => match m {
906                            Some(r) => {
907                                batch.push(r);
908                                batch.len() >= BATCH_MAX
909                            }
910                            None => {
911                                // Channel closed (store dropped): final
912                                // flush and stop.
913                                store.flush(&batch);
914                                return;
915                            }
916                        },
917                        _ = tick.tick() => true,
918                        _ = writer_shutdown.changed() => {
919                            // Drain what is already queued, then stop.
920                            while let Ok(r) = rx.try_recv() {
921                                batch.push(r);
922                            }
923                            store.flush(&batch);
924                            store.maintain();
925                            return;
926                        }
927                    };
928                    if drained {
929                        // REL-10 (#221): decrement the in-flight
930                        // counter by the batch size so the offer path's
931                        // fill-ratio check stays accurate.
932                        store.in_flight.fetch_sub(batch.len(), Ordering::Relaxed);
933                        store.flush(&batch);
934                        batch.clear();
935                    }
936                }
937            })
938        };
939        // DW-079: the spend writer — same fire-and-forget batched
940        // transaction shape as the raw writer, draining the spend
941        // channel into the `ai_spend` table.
942        let spend_writer = if let Some(mut spend_rx) = spend_rx_opt {
943            let store = Arc::clone(self);
944            let mut spend_shutdown = shutdown.clone();
945            Some(tokio::spawn(async move {
946                let mut tick =
947                    tokio::time::interval(std::time::Duration::from_millis(store.flush_ms));
948                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
949                let mut batch: Vec<AiSpendRecord> = Vec::with_capacity(BATCH_MAX);
950                loop {
951                    let drained = tokio::select! {
952                        m = spend_rx.recv() => match m {
953                            Some(r) => {
954                                batch.push(r);
955                                batch.len() >= BATCH_MAX
956                            }
957                            None => {
958                                store.flush_spend(&batch);
959                                return;
960                            }
961                        },
962                        _ = tick.tick() => true,
963                        _ = spend_shutdown.changed() => {
964                            while let Ok(r) = spend_rx.try_recv() {
965                                batch.push(r);
966                            }
967                            store.flush_spend(&batch);
968                            return;
969                        }
970                    };
971                    if drained {
972                        store.flush_spend(&batch);
973                        batch.clear();
974                    }
975                }
976            }))
977        } else {
978            None
979        };
980        // DW-084: the governance event writer — same fire-and-forget
981        // batched transaction shape as the spend writer, draining the
982        // governance channel into the `ai_governance_events` table.
983        let gov_writer = if let Some(mut gov_rx) = gov_rx_opt {
984            let store = Arc::clone(self);
985            let mut gov_shutdown = shutdown.clone();
986            Some(tokio::spawn(async move {
987                let mut tick =
988                    tokio::time::interval(std::time::Duration::from_millis(store.flush_ms));
989                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
990                let mut batch: Vec<AiGovernanceEvent> = Vec::with_capacity(BATCH_MAX);
991                loop {
992                    let drained = tokio::select! {
993                        m = gov_rx.recv() => match m {
994                            Some(r) => {
995                                batch.push(r);
996                                batch.len() >= BATCH_MAX
997                            }
998                            None => {
999                                store.flush_governance(&batch);
1000                                return;
1001                            }
1002                        },
1003                        _ = tick.tick() => true,
1004                        _ = gov_shutdown.changed() => {
1005                            while let Ok(r) = gov_rx.try_recv() {
1006                                batch.push(r);
1007                            }
1008                            store.flush_governance(&batch);
1009                            return;
1010                        }
1011                    };
1012                    if drained {
1013                        store.flush_governance(&batch);
1014                        batch.clear();
1015                    }
1016                }
1017            }))
1018        } else {
1019            None
1020        };
1021        // DW-081: the prompt log writer — same fire-and-forget batched
1022        // transaction shape as the governance writer, draining the
1023        // prompt log channel into the `ai_prompt_logs` table.
1024        let prompt_log_writer = if let Some(mut prompt_log_rx) = prompt_log_rx_opt {
1025            let store = Arc::clone(self);
1026            let mut prompt_log_shutdown = shutdown.clone();
1027            Some(tokio::spawn(async move {
1028                let mut tick =
1029                    tokio::time::interval(std::time::Duration::from_millis(store.flush_ms));
1030                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1031                let mut batch: Vec<AiPromptLogRecord> = Vec::with_capacity(BATCH_MAX);
1032                loop {
1033                    let drained = tokio::select! {
1034                        m = prompt_log_rx.recv() => match m {
1035                            Some(r) => {
1036                                batch.push(r);
1037                                batch.len() >= BATCH_MAX
1038                            }
1039                            None => {
1040                                store.flush_prompt_log(&batch);
1041                                return;
1042                            }
1043                        },
1044                        _ = tick.tick() => true,
1045                        _ = prompt_log_shutdown.changed() => {
1046                            while let Ok(r) = prompt_log_rx.try_recv() {
1047                                batch.push(r);
1048                            }
1049                            store.flush_prompt_log(&batch);
1050                            return;
1051                        }
1052                    };
1053                    if drained {
1054                        store.flush_prompt_log(&batch);
1055                        batch.clear();
1056                    }
1057                }
1058            }))
1059        } else {
1060            None
1061        };
1062        // DW-086: the experiment assignment writer — same
1063        // fire-and-forget batched transaction shape, draining the
1064        // assignment channel into the `ai_experiment_assignments` table.
1065        let exp_assign_writer = if let Some(mut exp_assign_rx) = exp_assign_rx_opt {
1066            let store = Arc::clone(self);
1067            let mut exp_assign_shutdown = shutdown.clone();
1068            Some(tokio::spawn(async move {
1069                let mut tick =
1070                    tokio::time::interval(std::time::Duration::from_millis(store.flush_ms));
1071                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1072                let mut batch: Vec<AiExperimentAssignment> = Vec::with_capacity(BATCH_MAX);
1073                loop {
1074                    let drained = tokio::select! {
1075                        m = exp_assign_rx.recv() => match m {
1076                            Some(r) => {
1077                                batch.push(r);
1078                                batch.len() >= BATCH_MAX
1079                            }
1080                            None => {
1081                                store.flush_exp_assign(&batch);
1082                                return;
1083                            }
1084                        },
1085                        _ = tick.tick() => true,
1086                        _ = exp_assign_shutdown.changed() => {
1087                            while let Ok(r) = exp_assign_rx.try_recv() {
1088                                batch.push(r);
1089                            }
1090                            store.flush_exp_assign(&batch);
1091                            return;
1092                        }
1093                    };
1094                    if drained {
1095                        store.flush_exp_assign(&batch);
1096                        batch.clear();
1097                    }
1098                }
1099            }))
1100        } else {
1101            None
1102        };
1103        // DW-086: the eval result writer — same fire-and-forget
1104        // batched transaction shape, draining the eval result channel
1105        // into the `ai_eval_results` table.
1106        let eval_result_writer = if let Some(mut eval_result_rx) = eval_result_rx_opt {
1107            let store = Arc::clone(self);
1108            let mut eval_result_shutdown = shutdown.clone();
1109            Some(tokio::spawn(async move {
1110                let mut tick =
1111                    tokio::time::interval(std::time::Duration::from_millis(store.flush_ms));
1112                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1113                let mut batch: Vec<AiEvalResultRecord> = Vec::with_capacity(BATCH_MAX);
1114                loop {
1115                    let drained = tokio::select! {
1116                        m = eval_result_rx.recv() => match m {
1117                            Some(r) => {
1118                                batch.push(r);
1119                                batch.len() >= BATCH_MAX
1120                            }
1121                            None => {
1122                                store.flush_eval_result(&batch);
1123                                return;
1124                            }
1125                        },
1126                        _ = tick.tick() => true,
1127                        _ = eval_result_shutdown.changed() => {
1128                            while let Ok(r) = eval_result_rx.try_recv() {
1129                                batch.push(r);
1130                            }
1131                            store.flush_eval_result(&batch);
1132                            return;
1133                        }
1134                    };
1135                    if drained {
1136                        store.flush_eval_result(&batch);
1137                        batch.clear();
1138                    }
1139                }
1140            }))
1141        } else {
1142            None
1143        };
1144        // DW-086: the feedback writer — same fire-and-forget batched
1145        // transaction shape, draining the feedback channel into the
1146        // `ai_feedback` table.
1147        let feedback_writer = if let Some(mut feedback_rx) = feedback_rx_opt {
1148            let store = Arc::clone(self);
1149            let mut feedback_shutdown = shutdown.clone();
1150            Some(tokio::spawn(async move {
1151                let mut tick =
1152                    tokio::time::interval(std::time::Duration::from_millis(store.flush_ms));
1153                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1154                let mut batch: Vec<AiFeedbackRecord> = Vec::with_capacity(BATCH_MAX);
1155                loop {
1156                    let drained = tokio::select! {
1157                        m = feedback_rx.recv() => match m {
1158                            Some(r) => {
1159                                batch.push(r);
1160                                batch.len() >= BATCH_MAX
1161                            }
1162                            None => {
1163                                store.flush_feedback(&batch);
1164                                return;
1165                            }
1166                        },
1167                        _ = tick.tick() => true,
1168                        _ = feedback_shutdown.changed() => {
1169                            while let Ok(r) = feedback_rx.try_recv() {
1170                                batch.push(r);
1171                            }
1172                            store.flush_feedback(&batch);
1173                            return;
1174                        }
1175                    };
1176                    if drained {
1177                        store.flush_feedback(&batch);
1178                        batch.clear();
1179                    }
1180                }
1181            }))
1182        } else {
1183            None
1184        };
1185        // DW-087: the MCP tool call writer — same fire-and-forget
1186        // batched transaction shape, draining the MCP channel into
1187        // the `mcp_tool_calls` table.
1188        let mcp_writer = if let Some(mut mcp_rx) = mcp_rx_opt {
1189            let store = Arc::clone(self);
1190            let mut mcp_shutdown = shutdown.clone();
1191            Some(tokio::spawn(async move {
1192                let mut tick =
1193                    tokio::time::interval(std::time::Duration::from_millis(store.flush_ms));
1194                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1195                let mut batch: Vec<McpToolCallRecord> = Vec::with_capacity(BATCH_MAX);
1196                loop {
1197                    let drained = tokio::select! {
1198                        m = mcp_rx.recv() => match m {
1199                            Some(r) => {
1200                                batch.push(r);
1201                                batch.len() >= BATCH_MAX
1202                            }
1203                            None => {
1204                                store.flush_mcp_tool_call(&batch);
1205                                return;
1206                            }
1207                        },
1208                        _ = tick.tick() => true,
1209                        _ = mcp_shutdown.changed() => {
1210                            while let Ok(r) = mcp_rx.try_recv() {
1211                                batch.push(r);
1212                            }
1213                            store.flush_mcp_tool_call(&batch);
1214                            return;
1215                        }
1216                    };
1217                    if drained {
1218                        store.flush_mcp_tool_call(&batch);
1219                        batch.clear();
1220                    }
1221                }
1222            }))
1223        } else {
1224            None
1225        };
1226        let maintainer = {
1227            let store = Arc::clone(self);
1228            let mut shutdown2 = shutdown.clone();
1229            tokio::spawn(async move {
1230                let mut tick =
1231                    tokio::time::interval(std::time::Duration::from_millis(ROLLUP_INTERVAL_MS));
1232                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1233                loop {
1234                    tokio::select! {
1235                        _ = tick.tick() => store.maintain(),
1236                        _ = shutdown2.changed() => return,
1237                    }
1238                }
1239            })
1240        };
1241        let mut handles = vec![writer, maintainer];
1242        if let Some(h) = spend_writer {
1243            handles.push(h);
1244        }
1245        if let Some(h) = gov_writer {
1246            handles.push(h);
1247        }
1248        if let Some(h) = prompt_log_writer {
1249            handles.push(h);
1250        }
1251        if let Some(h) = exp_assign_writer {
1252            handles.push(h);
1253        }
1254        if let Some(h) = eval_result_writer {
1255            handles.push(h);
1256        }
1257        if let Some(h) = feedback_writer {
1258            handles.push(h);
1259        }
1260        if let Some(h) = mcp_writer {
1261            handles.push(h);
1262        }
1263        handles
1264    }
1265
1266    /// One batched transaction of raw INSERTs. Errors are logged and
1267    /// swallowed (a failed analytics batch must never propagate into
1268    /// the worker loop) — the records are lost, the counter is not
1269    /// bumped (they were accepted; the DISK failed).
1270    fn flush(&self, batch: &[RawRecord]) {
1271        if batch.is_empty() {
1272            return;
1273        }
1274        let conn = self.conn.lock().unwrap();
1275        let tx = match conn.unchecked_transaction() {
1276            Ok(t) => t,
1277            Err(e) => {
1278                tracing::warn!(
1279                    code = "analytics_batch_begin_failed",
1280                    "analytics batch lost: {e}"
1281                );
1282                return;
1283            }
1284        };
1285        let mut stmt = match tx.prepare(
1286            "INSERT INTO raw (ts_ms, request_id, correlation_id, listener,
1287                              route, consumer, upstream, method, status,
1288                              status_class, duration_ms, attempts,
1289                              rate_limited, broken, shed, bytes_in, bytes_out,
1290                              dims, request_headers_redacted, auth_identity)
1291             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
1292                     ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
1293        ) {
1294            Ok(s) => s,
1295            Err(e) => {
1296                tracing::warn!(
1297                    code = "analytics_batch_prepare_failed",
1298                    "analytics batch lost: {e}"
1299                );
1300                return;
1301            }
1302        };
1303        let mut ok = true;
1304        for r in batch {
1305            let res = stmt.execute(rusqlite::params![
1306                r.ts_ms,
1307                r.request_id,
1308                r.correlation_id,
1309                r.listener,
1310                r.route,
1311                r.consumer,
1312                r.upstream,
1313                r.method,
1314                r.status,
1315                r.status_class,
1316                r.duration_ms,
1317                r.attempts,
1318                r.rate_limited,
1319                r.broken,
1320                r.shed,
1321                r.bytes_in,
1322                r.bytes_out,
1323                r.dims,
1324                r.request_headers_redacted,
1325                r.auth_identity,
1326            ]);
1327            if let Err(e) = res {
1328                tracing::warn!(
1329                    code = "analytics_insert_failed",
1330                    "analytics record lost: {e}"
1331                );
1332                ok = false;
1333            }
1334        }
1335        drop(stmt);
1336        if ok || !batch.is_empty() {
1337            // Commit even with per-row failures: the surviving rows are
1338            // valid analytics input.
1339            if let Err(e) = tx.commit() {
1340                tracing::warn!(
1341                    code = "analytics_commit_failed",
1342                    "analytics batch lost: {e}"
1343                );
1344            }
1345        }
1346    }
1347
1348    /// One batched transaction of `ai_spend` INSERTs (DW-079). Same
1349    /// swallow-and-log posture as [`flush`]: a failed batch is lost,
1350    /// the counter is not bumped (the records were accepted; the DISK
1351    /// failed).
1352    fn flush_spend(&self, batch: &[AiSpendRecord]) {
1353        if batch.is_empty() {
1354            return;
1355        }
1356        let conn = self.conn.lock().unwrap();
1357        let tx = match conn.unchecked_transaction() {
1358            Ok(t) => t,
1359            Err(e) => {
1360                tracing::warn!(
1361                    code = "analytics_spend_batch_begin_failed",
1362                    "ai_spend batch lost: {e}"
1363                );
1364                return;
1365            }
1366        };
1367        let mut stmt = match tx.prepare(
1368            "INSERT INTO ai_spend (ts_ms, consumer, consumer_type, team, provider, model, version,
1369                                   prompt_tokens, completion_tokens, total_tokens, cost_micros)
1370             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1371        ) {
1372            Ok(s) => s,
1373            Err(e) => {
1374                tracing::warn!(
1375                    code = "analytics_spend_batch_prepare_failed",
1376                    "ai_spend batch lost: {e}"
1377                );
1378                return;
1379            }
1380        };
1381        let mut ok = true;
1382        for r in batch {
1383            let res = stmt.execute(rusqlite::params![
1384                r.ts_ms,
1385                r.consumer,
1386                r.consumer_type,
1387                r.team,
1388                r.provider,
1389                r.model,
1390                r.version,
1391                r.prompt_tokens as i64,
1392                r.completion_tokens as i64,
1393                r.total_tokens as i64,
1394                r.cost_micros as i64,
1395            ]);
1396            if let Err(e) = res {
1397                tracing::warn!(
1398                    code = "analytics_spend_insert_failed",
1399                    "ai_spend record lost: {e}"
1400                );
1401                ok = false;
1402            }
1403        }
1404        drop(stmt);
1405        if ok || !batch.is_empty() {
1406            if let Err(e) = tx.commit() {
1407                tracing::warn!(
1408                    code = "analytics_spend_commit_failed",
1409                    "ai_spend batch lost: {e}"
1410                );
1411            }
1412        }
1413    }
1414
1415    /// One batched transaction of `ai_governance_events` INSERTs
1416    /// (DW-084). Same swallow-and-log posture as [`flush_spend`]: a
1417    /// failed batch is lost, the counter is not bumped (the records
1418    /// were accepted; the DISK failed).
1419    fn flush_governance(&self, batch: &[AiGovernanceEvent]) {
1420        if batch.is_empty() {
1421            return;
1422        }
1423        let conn = self.conn.lock().unwrap();
1424        let tx = match conn.unchecked_transaction() {
1425            Ok(t) => t,
1426            Err(e) => {
1427                tracing::warn!(
1428                    code = "analytics_governance_batch_begin_failed",
1429                    "ai_governance_events batch lost: {e}"
1430                );
1431                return;
1432            }
1433        };
1434        let mut stmt = match tx.prepare(
1435            "INSERT INTO ai_governance_events \
1436             (ts_ms, consumer, team, model, verdict, reason) \
1437             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1438        ) {
1439            Ok(s) => s,
1440            Err(e) => {
1441                tracing::warn!(
1442                    code = "analytics_governance_batch_prepare_failed",
1443                    "ai_governance_events batch lost: {e}"
1444                );
1445                return;
1446            }
1447        };
1448        let mut ok = true;
1449        for r in batch {
1450            let res = stmt.execute(rusqlite::params![
1451                r.ts_ms, r.consumer, r.team, r.model, r.verdict, r.reason,
1452            ]);
1453            if let Err(e) = res {
1454                tracing::warn!(
1455                    code = "analytics_governance_insert_failed",
1456                    "ai_governance_events record lost: {e}"
1457                );
1458                ok = false;
1459            }
1460        }
1461        drop(stmt);
1462        if ok || !batch.is_empty() {
1463            if let Err(e) = tx.commit() {
1464                tracing::warn!(
1465                    code = "analytics_governance_commit_failed",
1466                    "ai_governance_events batch lost: {e}"
1467                );
1468            }
1469        }
1470    }
1471
1472    /// One batched transaction of `ai_prompt_logs` INSERTs (DW-081).
1473    /// Same swallow-and-log posture as [`flush_governance`]: a failed
1474    /// batch is lost, the counter is not bumped (the records were
1475    /// accepted; the DISK failed).
1476    fn flush_prompt_log(&self, batch: &[AiPromptLogRecord]) {
1477        if batch.is_empty() {
1478            return;
1479        }
1480        let conn = self.conn.lock().unwrap();
1481        let tx = match conn.unchecked_transaction() {
1482            Ok(t) => t,
1483            Err(e) => {
1484                tracing::warn!(
1485                    code = "analytics_prompt_log_batch_begin_failed",
1486                    "ai_prompt_logs batch lost: {e}"
1487                );
1488                return;
1489            }
1490        };
1491        let mut stmt = match tx.prepare(
1492            "INSERT INTO ai_prompt_logs \
1493             (ts_ms, request_id, consumer, route, provider, model, version, \
1494              prompt_json, response_json, stream) \
1495             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1496        ) {
1497            Ok(s) => s,
1498            Err(e) => {
1499                tracing::warn!(
1500                    code = "analytics_prompt_log_batch_prepare_failed",
1501                    "ai_prompt_logs batch lost: {e}"
1502                );
1503                return;
1504            }
1505        };
1506        let mut ok = true;
1507        for r in batch {
1508            let res = stmt.execute(rusqlite::params![
1509                r.ts_ms,
1510                r.request_id,
1511                r.consumer,
1512                r.route,
1513                r.provider,
1514                r.model,
1515                r.version,
1516                r.prompt_json,
1517                r.response_json,
1518                r.stream as i64,
1519            ]);
1520            if let Err(e) = res {
1521                tracing::warn!(
1522                    code = "analytics_prompt_log_insert_failed",
1523                    "ai_prompt_logs record lost: {e}"
1524                );
1525                ok = false;
1526            }
1527        }
1528        drop(stmt);
1529        if ok || !batch.is_empty() {
1530            if let Err(e) = tx.commit() {
1531                tracing::warn!(
1532                    code = "analytics_prompt_log_commit_failed",
1533                    "ai_prompt_logs batch lost: {e}"
1534                );
1535            }
1536        }
1537    }
1538
1539    /// One batched transaction of `ai_experiment_assignments` INSERTs
1540    /// (DW-086). Same swallow-and-log posture as the other flush
1541    /// methods.
1542    fn flush_exp_assign(&self, batch: &[AiExperimentAssignment]) {
1543        if batch.is_empty() {
1544            return;
1545        }
1546        let conn = self.conn.lock().unwrap();
1547        let tx = match conn.unchecked_transaction() {
1548            Ok(t) => t,
1549            Err(e) => {
1550                tracing::warn!(
1551                    code = "analytics_exp_assign_batch_begin_failed",
1552                    "ai_experiment_assignments batch lost: {e}"
1553                );
1554                return;
1555            }
1556        };
1557        let mut stmt = match tx.prepare(
1558            "INSERT INTO ai_experiment_assignments \
1559             (ts_ms, request_id, experiment, variant, model, consumer) \
1560             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1561        ) {
1562            Ok(s) => s,
1563            Err(e) => {
1564                tracing::warn!(
1565                    code = "analytics_exp_assign_batch_prepare_failed",
1566                    "ai_experiment_assignments batch lost: {e}"
1567                );
1568                return;
1569            }
1570        };
1571        let mut ok = true;
1572        for r in batch {
1573            let res = stmt.execute(rusqlite::params![
1574                r.ts_ms,
1575                r.request_id,
1576                r.experiment,
1577                r.variant,
1578                r.model,
1579                r.consumer,
1580            ]);
1581            if let Err(e) = res {
1582                tracing::warn!(
1583                    code = "analytics_exp_assign_insert_failed",
1584                    "ai_experiment_assignments record lost: {e}"
1585                );
1586                ok = false;
1587            }
1588        }
1589        drop(stmt);
1590        if ok || !batch.is_empty() {
1591            if let Err(e) = tx.commit() {
1592                tracing::warn!(
1593                    code = "analytics_exp_assign_commit_failed",
1594                    "ai_experiment_assignments batch lost: {e}"
1595                );
1596            }
1597        }
1598    }
1599
1600    /// One batched transaction of `ai_eval_results` INSERTs (DW-086).
1601    /// Same swallow-and-log posture as the other flush methods.
1602    fn flush_eval_result(&self, batch: &[AiEvalResultRecord]) {
1603        if batch.is_empty() {
1604            return;
1605        }
1606        let conn = self.conn.lock().unwrap();
1607        let tx = match conn.unchecked_transaction() {
1608            Ok(t) => t,
1609            Err(e) => {
1610                tracing::warn!(
1611                    code = "analytics_eval_result_batch_begin_failed",
1612                    "ai_eval_results batch lost: {e}"
1613                );
1614                return;
1615            }
1616        };
1617        let mut stmt = match tx.prepare(
1618            "INSERT INTO ai_eval_results \
1619             (ts_ms, eval_name, model, variant, prompt_version, case_index, \
1620              input, expected, actual, passed, scorer, latency_ms) \
1621             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1622        ) {
1623            Ok(s) => s,
1624            Err(e) => {
1625                tracing::warn!(
1626                    code = "analytics_eval_result_batch_prepare_failed",
1627                    "ai_eval_results batch lost: {e}"
1628                );
1629                return;
1630            }
1631        };
1632        let mut ok = true;
1633        for r in batch {
1634            let res = stmt.execute(rusqlite::params![
1635                r.ts_ms,
1636                r.eval_name,
1637                r.model,
1638                r.variant,
1639                r.prompt_version,
1640                r.case_index as i64,
1641                r.input,
1642                r.expected,
1643                r.actual,
1644                r.passed as i64,
1645                r.scorer,
1646                r.latency_ms,
1647            ]);
1648            if let Err(e) = res {
1649                tracing::warn!(
1650                    code = "analytics_eval_result_insert_failed",
1651                    "ai_eval_results record lost: {e}"
1652                );
1653                ok = false;
1654            }
1655        }
1656        drop(stmt);
1657        if ok || !batch.is_empty() {
1658            if let Err(e) = tx.commit() {
1659                tracing::warn!(
1660                    code = "analytics_eval_result_commit_failed",
1661                    "ai_eval_results batch lost: {e}"
1662                );
1663            }
1664        }
1665    }
1666
1667    /// One batched transaction of `ai_feedback` INSERTs (DW-086).
1668    /// Same swallow-and-log posture as the other flush methods.
1669    fn flush_feedback(&self, batch: &[AiFeedbackRecord]) {
1670        if batch.is_empty() {
1671            return;
1672        }
1673        let conn = self.conn.lock().unwrap();
1674        let tx = match conn.unchecked_transaction() {
1675            Ok(t) => t,
1676            Err(e) => {
1677                tracing::warn!(
1678                    code = "analytics_feedback_batch_begin_failed",
1679                    "ai_feedback batch lost: {e}"
1680                );
1681                return;
1682            }
1683        };
1684        let mut stmt = match tx.prepare(
1685            "INSERT INTO ai_feedback \
1686             (ts_ms, request_id, label, comment, consumer, model) \
1687             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1688        ) {
1689            Ok(s) => s,
1690            Err(e) => {
1691                tracing::warn!(
1692                    code = "analytics_feedback_batch_prepare_failed",
1693                    "ai_feedback batch lost: {e}"
1694                );
1695                return;
1696            }
1697        };
1698        let mut ok = true;
1699        for r in batch {
1700            let res = stmt.execute(rusqlite::params![
1701                r.ts_ms,
1702                r.request_id,
1703                r.label,
1704                r.comment,
1705                r.consumer,
1706                r.model,
1707            ]);
1708            if let Err(e) = res {
1709                tracing::warn!(
1710                    code = "analytics_feedback_insert_failed",
1711                    "ai_feedback record lost: {e}"
1712                );
1713                ok = false;
1714            }
1715        }
1716        drop(stmt);
1717        if ok || !batch.is_empty() {
1718            if let Err(e) = tx.commit() {
1719                tracing::warn!(
1720                    code = "analytics_feedback_commit_failed",
1721                    "ai_feedback batch lost: {e}"
1722                );
1723            }
1724        }
1725    }
1726
1727    /// One batched transaction of `mcp_tool_calls` INSERTs (DW-087).
1728    /// Same swallow-and-log posture as the other flush methods.
1729    fn flush_mcp_tool_call(&self, batch: &[McpToolCallRecord]) {
1730        if batch.is_empty() {
1731            return;
1732        }
1733        let conn = self.conn.lock().unwrap();
1734        let tx = match conn.unchecked_transaction() {
1735            Ok(t) => t,
1736            Err(e) => {
1737                tracing::warn!(
1738                    code = "analytics_mcp_batch_begin_failed",
1739                    "mcp_tool_calls batch lost: {e}"
1740                );
1741                return;
1742            }
1743        };
1744        let mut stmt = match tx.prepare(
1745            "INSERT INTO mcp_tool_calls \
1746             (ts_ms, request_id, session_id, consumer, consumer_type, tool_name, allowed, \
1747              duration_ms, error_code, status) \
1748             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1749        ) {
1750            Ok(s) => s,
1751            Err(e) => {
1752                tracing::warn!(
1753                    code = "analytics_mcp_batch_prepare_failed",
1754                    "mcp_tool_calls batch lost: {e}"
1755                );
1756                return;
1757            }
1758        };
1759        let mut ok = true;
1760        for r in batch {
1761            let res = stmt.execute(rusqlite::params![
1762                r.ts_ms,
1763                r.request_id,
1764                r.session_id,
1765                r.consumer,
1766                r.consumer_type,
1767                r.tool_name,
1768                r.allowed as i64,
1769                r.duration_ms,
1770                r.error_code,
1771                r.status,
1772            ]);
1773            if let Err(e) = res {
1774                tracing::warn!(
1775                    code = "analytics_mcp_insert_failed",
1776                    "mcp_tool_calls record lost: {e}"
1777                );
1778                ok = false;
1779            }
1780        }
1781        drop(stmt);
1782        if ok || !batch.is_empty() {
1783            if let Err(e) = tx.commit() {
1784                tracing::warn!(
1785                    code = "analytics_mcp_commit_failed",
1786                    "mcp_tool_calls batch lost: {e}"
1787                );
1788            }
1789        }
1790    }
1791
1792    /// One rollup + retention pass (cursor-guarded; safe to run at any
1793    /// time, from the ticker or shutdown).
1794    fn maintain(&self) {
1795        let conn = self.conn.lock().unwrap();
1796        let now = now_ms();
1797        // SCALE-09 (#188): rotate the raw table into a daily partition
1798        // if it has rows from a previous day. This bounds the raw
1799        // table to the current day and makes retention O(1) (DROP
1800        // TABLE) for old partitions.
1801        if let Err(e) = partition::maybe_rotate(&conn, now) {
1802            tracing::warn!(
1803                code = "analytics_partition_rotate_failed",
1804                "raw partition rotation failed: {e}"
1805            );
1806        }
1807        let mut rolled = 0usize;
1808        match rollup::roll_raw_to_1m(&conn, now, ROLLUP_GRACE_MS) {
1809            Ok(n) => rolled = n,
1810            Err(e) => tracing::warn!(
1811                code = "analytics_rollup_failed",
1812                "raw->1m rollup failed: {e}"
1813            ),
1814        }
1815        for stage in 0..rollup::GRANULARITIES_MS.len() - 1 {
1816            if let Err(e) = rollup::roll_cascade(&conn, stage, now, ROLLUP_GRACE_MS) {
1817                tracing::warn!(
1818                    code = "analytics_cascade_failed",
1819                    "cascade stage {stage} failed: {e}"
1820                );
1821            }
1822        }
1823        if let Err(e) = rollup::sweep_retention(
1824            &conn,
1825            self.retention_ms[0],
1826            self.retention_ms[1..].try_into().unwrap(),
1827            now,
1828            256,
1829        ) {
1830            tracing::warn!(
1831                code = "analytics_retention_failed",
1832                "retention sweep failed: {e}"
1833            );
1834        } else if rolled > 0 {
1835            tracing::debug!(
1836                code = "analytics_rollup",
1837                windows = rolled,
1838                "rollup pass complete"
1839            );
1840        }
1841        // SCALE-09 (#188): drop expired daily partition tables (O(1)
1842        // DROP TABLE vs O(n) DELETE rows).
1843        if let Err(e) = partition::drop_expired_partitions(&conn, self.retention_ms[0], now) {
1844            tracing::warn!(
1845                code = "analytics_partition_drop_failed",
1846                "expired partition drop failed: {e}"
1847            );
1848        }
1849        // DW-081: prompt log retention sweep. Records older than the
1850        // configured retention window are deleted. A retention of 0
1851        // means no sweep (logging is off or unconfigured).
1852        let retention_ms = self.prompt_log_retention_ms.load(Ordering::Relaxed);
1853        if retention_ms > 0 {
1854            let cutoff = now.saturating_sub(retention_ms);
1855            if let Err(e) = conn.execute("DELETE FROM ai_prompt_logs WHERE ts_ms < ?1", [cutoff]) {
1856                tracing::warn!(
1857                    code = "analytics_prompt_log_retention_failed",
1858                    "ai_prompt_logs retention sweep failed: {e}"
1859                );
1860            }
1861        }
1862    }
1863
1864    /// Records accepted by `record` but dropped (channel full). The
1865    /// honest loss counter for the never-block posture.
1866    pub fn dropped_records(&self) -> u64 {
1867        self.dropped.load(Ordering::Relaxed)
1868    }
1869
1870    /// REL-10 (#221): records dropped by sampled degradation (the
1871    /// probabilistic pre-full drop). Counted separately from
1872    /// [`Self::dropped_records`] so an operator can distinguish "channel
1873    /// was full" from "sampling kicked in to avoid filling the channel".
1874    pub fn dropped_sampled_records(&self) -> u64 {
1875        self.sampled_dropped.load(Ordering::Relaxed)
1876    }
1877
1878    /// Run a short locked access against the store's connection (the
1879    /// admin read path, and the exports ledger's single-row record
1880    /// writes). The connection is shared with the writer behind one
1881    /// mutex; access is sized for the "week under 100 ms" contract, so
1882    /// contention is short and rare.
1883    pub fn query<T>(
1884        &self,
1885        f: impl FnOnce(&rusqlite::Connection) -> rusqlite::Result<T>,
1886    ) -> rusqlite::Result<T> {
1887        let conn = self.conn.lock().unwrap();
1888        f(&conn)
1889    }
1890
1891    /// REL-13 (#224): back up the analytics database to `dest_path`
1892    /// using SQLite's online backup API. The backup is a consistent
1893    /// snapshot taken under the connection mutex (the writer is
1894    /// briefly blocked). The destination file is created or
1895    /// overwritten.
1896    pub fn backup_to(&self, dest_path: &str) -> rusqlite::Result<()> {
1897        let conn = self.conn.lock().unwrap();
1898        let mut dst = rusqlite::Connection::open(dest_path)?;
1899        let backup = rusqlite::backup::Backup::new(&conn, &mut dst)?;
1900        backup.run_to_completion(100, std::time::Duration::from_millis(10), None)?;
1901        Ok(())
1902    }
1903
1904    /// REL-13 (#224): restore the analytics database from `src_path`.
1905    /// The source file is opened and its contents are copied into the
1906    /// live connection via the online backup API. The writer is
1907    /// briefly blocked under the connection mutex. After restore, the
1908    /// in-memory state (live sketches, insights) is NOT reset — they
1909    /// continue from their current window.
1910    pub fn restore_from(&self, src_path: &str) -> rusqlite::Result<()> {
1911        let mut conn = self.conn.lock().unwrap();
1912        let src = rusqlite::Connection::open(src_path)?;
1913        let backup = rusqlite::backup::Backup::new(&src, &mut conn)?;
1914        backup.run_to_completion(100, std::time::Duration::from_millis(10), None)?;
1915        Ok(())
1916    }
1917
1918    /// The request-completion hot path (DW-043): fire-and-forget record
1919    /// of one finished request. NEVER blocks — a bounded-channel
1920    /// `try_send` that drops and counts on full. DW-092: also feeds the
1921    /// live in-process sketches (synchronous, lock-free counts + a
1922    /// short mutex over a capped sample vector) when attached.
1923    pub fn record(&self, rec: &AccessRecord) {
1924        let ts = now_ms();
1925        // DW-092: feed the live sketches synchronously (never blocks —
1926        // atomics for counts, a short mutex over a capped vector).
1927        if let Ok(live) = self.live.lock() {
1928            if let Some(sketches) = live.as_ref() {
1929                sketches.record(&rec.route, rec.status, rec.duration_ms, ts as u64);
1930            }
1931        }
1932        let raw = RawRecord::from(rec, ts);
1933        self.offer(raw);
1934    }
1935
1936    /// Attach the live sketches (DW-092). Called once during startup
1937    /// wiring (dwara-bin) when `analytics.live_sketches.enabled`. The
1938    /// insights engine, when also configured, is attached to the
1939    /// sketches so window rotations feed it.
1940    pub fn set_live_sketches(&self, sketches: Arc<LiveSketches>) {
1941        *self.live.lock().unwrap() = Some(sketches);
1942    }
1943
1944    /// Attach the insights engine (DW-092). Called once during startup
1945    /// wiring when `analytics.insights.forecast` or
1946    /// `analytics.insights.anomaly_baseline`. Also wired into the live
1947    /// sketches so window rotations feed the engine.
1948    pub fn set_insights(&self, engine: Arc<insights::InsightsEngine>) {
1949        if let Ok(live) = self.live.lock() {
1950            if let Some(sketches) = live.as_ref() {
1951                sketches.set_insights(Arc::clone(&engine));
1952            }
1953        }
1954        *self.insights.lock().unwrap() = Some(engine);
1955    }
1956
1957    /// The live sketches store when attached (DW-092; None = the
1958    /// `GET /analytics/live` endpoint answers
1959    /// `analytics_not_configured`).
1960    pub fn live_sketches(&self) -> Option<Arc<LiveSketches>> {
1961        self.live.lock().unwrap().as_ref().map(Arc::clone)
1962    }
1963
1964    /// The insights engine when attached (DW-092; None = the
1965    /// `GET /analytics/forecast` and `GET /analytics/anomalies`
1966    /// endpoints answer `analytics_not_configured`).
1967    pub fn insights_engine(&self) -> Option<Arc<insights::InsightsEngine>> {
1968        self.insights.lock().unwrap().as_ref().map(Arc::clone)
1969    }
1970
1971    /// Snapshot the current live window (DW-092). Returns None when
1972    /// live sketches are not attached.
1973    pub fn live_snapshot(&self) -> Option<LiveSnapshot> {
1974        let live = self.live.lock().unwrap();
1975        live.as_ref().map(|s| s.snapshot(now_ms() as u64))
1976    }
1977
1978    /// Forecast the next window (DW-092). Returns None when the
1979    /// insights engine is not attached or forecasting is disabled.
1980    pub fn insights_forecast(&self) -> Option<insights::ForecastResult> {
1981        let engine = self.insights.lock().unwrap();
1982        engine.as_ref().map(|e| e.forecast(now_ms()))
1983    }
1984
1985    /// Detect whether the current live window is anomalous (DW-092).
1986    /// Builds a `BaselineWindow` from the current live snapshot and
1987    /// hands it to the insights engine. Returns None when the insights
1988    /// engine is not attached or anomaly detection is disabled.
1989    pub fn insights_detect_anomaly(&self) -> Option<insights::AnomalyResult> {
1990        // Clone the engine Arc and drop the insights lock BEFORE
1991        // acquiring the live lock — `set_insights` takes the live lock
1992        // first, so locking insights-then-live here would invert the
1993        // order and risk a deadlock.
1994        let engine = {
1995            let guard = self.insights.lock().unwrap();
1996            guard.as_ref()?.clone()
1997        };
1998        // Build the current window from the live snapshot so the
1999        // anomaly check reflects the in-flight window, not a stale
2000        // rotation.
2001        let snapshot = self.live_snapshot()?;
2002        let mut total_requests: u64 = 0;
2003        let mut total_errors: u64 = 0;
2004        let mut latency_sum: f64 = 0.0;
2005        let mut latency_count: u64 = 0;
2006        for r in &snapshot.routes {
2007            total_requests += r.requests;
2008            total_errors += r.errors;
2009            latency_sum += r.avg_ms * r.requests as f64;
2010            latency_count += r.requests;
2011        }
2012        let avg_latency = if latency_count > 0 {
2013            latency_sum / latency_count as f64
2014        } else {
2015            0.0
2016        };
2017        let current = insights::BaselineWindow {
2018            ts_ms: snapshot.window_start_ms as i64,
2019            requests: total_requests,
2020            errors: total_errors,
2021            avg_latency_ms: avg_latency,
2022        };
2023        Some(engine.detect_anomaly(&current))
2024    }
2025
2026    /// Offer one raw record to the writer channel; drop-and-count on a
2027    /// full channel (the never-block policy shared by the hot path and
2028    /// the extension-seam impl below). REL-10 (#221): when sampled
2029    /// degradation is configured, probabilistically drops records when
2030    /// the channel fill ratio is above `high_watermark` to avoid a
2031    /// hard drop cliff.
2032    fn offer(&self, raw: RawRecord) {
2033        // REL-10 (#221): sampled degradation. Check the channel fill
2034        // ratio and probabilistically drop records above the high
2035        // watermark. Hysteresis: once sampling is active, it stays
2036        // active until the fill ratio drops below `low_watermark`.
2037        if let Some(cfg) = self.sampled_degradation {
2038            let in_flight = self.in_flight.load(Ordering::Relaxed) as f64;
2039            let fill_ratio = in_flight / CHANNEL_CAP as f64;
2040            let mut active = self.sampling_active.load(Ordering::Relaxed);
2041            // Update hysteresis state.
2042            if !active && fill_ratio > cfg.high_watermark {
2043                let _ = self.sampling_active.compare_exchange(
2044                    false,
2045                    true,
2046                    Ordering::Relaxed,
2047                    Ordering::Relaxed,
2048                );
2049                active = true;
2050            } else if active && fill_ratio < cfg.low_watermark {
2051                let _ = self.sampling_active.compare_exchange(
2052                    true,
2053                    false,
2054                    Ordering::Relaxed,
2055                    Ordering::Relaxed,
2056                );
2057                active = false;
2058            }
2059            if active {
2060                // Sample: keep only `keep_rate` fraction of records.
2061                // Use a fast thread-local PRNG to avoid lock contention.
2062                use std::cell::Cell;
2063                thread_local! {
2064                    static SAMPLE_RNG: Cell<u64> = const { Cell::new(0x9E3779B97F4A7C15) };
2065                }
2066                let keep = SAMPLE_RNG.with(|rng| {
2067                    let s = rng.get();
2068                    // xorshift64 (never returns 0 after the first step).
2069                    let mut x = s.wrapping_add(0x9E3779B97F4A7C15) | 1;
2070                    x ^= x << 13;
2071                    x ^= x >> 7;
2072                    x ^= x << 17;
2073                    rng.set(x);
2074                    x
2075                });
2076                // Map to [0, 1) and compare against keep_rate.
2077                let unit = (keep >> 11) as f64 / (1u64 << 53) as f64;
2078                if unit >= cfg.keep_rate {
2079                    // Drop this record (sampled).
2080                    let n = self.sampled_dropped.fetch_add(1, Ordering::Relaxed);
2081                    if n.is_multiple_of(4096) {
2082                        tracing::warn!(
2083                            code = "analytics_sampled_drop",
2084                            total_sampled_dropped = n + 1,
2085                            fill_ratio,
2086                            keep_rate = cfg.keep_rate,
2087                            "analytics channel above high watermark; sampling records \
2088                             (never blocking the dataplane)"
2089                        );
2090                    }
2091                    return;
2092                }
2093            }
2094        }
2095        if self.tx.try_send(raw).is_err() {
2096            let n = self.dropped.fetch_add(1, Ordering::Relaxed);
2097            if n.is_multiple_of(4096) {
2098                tracing::warn!(
2099                    code = "analytics_channel_full",
2100                    total_dropped = n + 1,
2101                    "analytics channel full; dropping records (never blocking the dataplane)"
2102                );
2103            }
2104        } else {
2105            // Track in-flight for the fill-ratio check.
2106            self.in_flight.fetch_add(1, Ordering::Relaxed);
2107        }
2108    }
2109
2110    /// The AI spend hot path (DW-079): fire-and-forget record of one
2111    /// completed AI request's spend dimensions. NEVER blocks — a
2112    /// bounded-channel `try_send` that drops and counts on full (the
2113    /// same never-block posture as [`Self::record`]).
2114    pub fn offer_ai_spend(&self, rec: AiSpendRecord) {
2115        if self.spend_tx.try_send(rec).is_err() {
2116            let n = self.spend_dropped.fetch_add(1, Ordering::Relaxed);
2117            if n.is_multiple_of(4096) {
2118                tracing::warn!(
2119                    code = "analytics_spend_channel_full",
2120                    total_dropped = n + 1,
2121                    "ai_spend channel full; dropping records (never blocking the dataplane)"
2122                );
2123            }
2124        }
2125    }
2126
2127    /// AI spend records accepted by [`Self::offer_ai_spend`] but dropped
2128    /// (channel full). The honest loss counter for the DW-079 path.
2129    pub fn dropped_spend_records(&self) -> u64 {
2130        self.spend_dropped.load(Ordering::Relaxed)
2131    }
2132
2133    /// The AI governance event hot path (DW-084): fire-and-forget
2134    /// record of one governance check outcome. NEVER blocks — a
2135    /// bounded-channel `try_send` that drops and counts on full (the
2136    /// same never-block posture as [`Self::offer_ai_spend`]).
2137    pub fn offer_ai_governance_event(&self, rec: AiGovernanceEvent) {
2138        if self.gov_tx.try_send(rec).is_err() {
2139            let n = self.gov_dropped.fetch_add(1, Ordering::Relaxed);
2140            if n.is_multiple_of(4096) {
2141                tracing::warn!(
2142                    code = "analytics_governance_channel_full",
2143                    total_dropped = n + 1,
2144                    "ai_governance_events channel full; dropping records (never blocking the dataplane)"
2145                );
2146            }
2147        }
2148    }
2149
2150    /// AI governance events accepted by
2151    /// [`Self::offer_ai_governance_event`] but dropped (channel full).
2152    /// The honest loss counter for the DW-084 path.
2153    pub fn dropped_governance_events(&self) -> u64 {
2154        self.gov_dropped.load(Ordering::Relaxed)
2155    }
2156
2157    /// The AI prompt log hot path (DW-081): fire-and-forget record of
2158    /// one captured prompt/response pair (already redacted). NEVER
2159    /// blocks — a bounded-channel `try_send` that drops and counts on
2160    /// full (the same never-block posture as
2161    /// [`Self::offer_ai_governance_event`]).
2162    pub fn offer_ai_prompt_log(&self, rec: AiPromptLogRecord) {
2163        if self.prompt_log_tx.try_send(rec).is_err() {
2164            let n = self.prompt_log_dropped.fetch_add(1, Ordering::Relaxed);
2165            if n.is_multiple_of(4096) {
2166                tracing::warn!(
2167                    code = "analytics_prompt_log_channel_full",
2168                    total_dropped = n + 1,
2169                    "ai_prompt_logs channel full; dropping records (never blocking the dataplane)"
2170                );
2171            }
2172        }
2173    }
2174
2175    /// AI prompt log records accepted by [`Self::offer_ai_prompt_log`]
2176    /// but dropped (channel full). The honest loss counter for the
2177    /// DW-081 path.
2178    pub fn dropped_prompt_log_records(&self) -> u64 {
2179        self.prompt_log_dropped.load(Ordering::Relaxed)
2180    }
2181
2182    /// Update the prompt log retention window (DW-081). Called on
2183    /// reload when the logging config changes. A retention of 0
2184    /// disables the sweep.
2185    pub fn set_prompt_log_retention_ms(&self, retention_ms: i64) {
2186        self.prompt_log_retention_ms
2187            .store(retention_ms, Ordering::Relaxed);
2188    }
2189
2190    /// The experiment assignment hot path (DW-086): fire-and-forget
2191    /// record of one A/B test variant selection. NEVER blocks — a
2192    /// bounded-channel `try_send` that drops and counts on full.
2193    pub fn offer_ai_experiment_assignment(&self, rec: AiExperimentAssignment) {
2194        if self.exp_assign_tx.try_send(rec).is_err() {
2195            let n = self.exp_assign_dropped.fetch_add(1, Ordering::Relaxed);
2196            if n.is_multiple_of(4096) {
2197                tracing::warn!(
2198                    code = "analytics_exp_assign_channel_full",
2199                    total_dropped = n + 1,
2200                    "ai_experiment_assignments channel full; dropping records \
2201                     (never blocking the dataplane)"
2202                );
2203            }
2204        }
2205    }
2206
2207    /// Experiment assignment records accepted but dropped (channel
2208    /// full). The honest loss counter for the DW-086 assignment path.
2209    pub fn dropped_exp_assign_records(&self) -> u64 {
2210        self.exp_assign_dropped.load(Ordering::Relaxed)
2211    }
2212
2213    /// The eval result path (DW-086): fire-and-forget record of one
2214    /// eval case result. NEVER blocks — a bounded-channel `try_send`
2215    /// that drops and counts on full.
2216    pub fn offer_ai_eval_result(&self, rec: AiEvalResultRecord) {
2217        if self.eval_result_tx.try_send(rec).is_err() {
2218            let n = self.eval_result_dropped.fetch_add(1, Ordering::Relaxed);
2219            if n.is_multiple_of(4096) {
2220                tracing::warn!(
2221                    code = "analytics_eval_result_channel_full",
2222                    total_dropped = n + 1,
2223                    "ai_eval_results channel full; dropping records"
2224                );
2225            }
2226        }
2227    }
2228
2229    /// Eval result records accepted but dropped (channel full).
2230    pub fn dropped_eval_result_records(&self) -> u64 {
2231        self.eval_result_dropped.load(Ordering::Relaxed)
2232    }
2233
2234    /// The feedback path (DW-086): fire-and-forget record of one
2235    /// feedback entry. NEVER blocks — a bounded-channel `try_send`
2236    /// that drops and counts on full.
2237    pub fn offer_ai_feedback(&self, rec: AiFeedbackRecord) {
2238        if self.feedback_tx.try_send(rec).is_err() {
2239            let n = self.feedback_dropped.fetch_add(1, Ordering::Relaxed);
2240            if n.is_multiple_of(4096) {
2241                tracing::warn!(
2242                    code = "analytics_feedback_channel_full",
2243                    total_dropped = n + 1,
2244                    "ai_feedback channel full; dropping records"
2245                );
2246            }
2247        }
2248    }
2249
2250    /// Feedback records accepted but dropped (channel full).
2251    pub fn dropped_feedback_records(&self) -> u64 {
2252        self.feedback_dropped.load(Ordering::Relaxed)
2253    }
2254
2255    /// The MCP tool call hot path (DW-087): fire-and-forget record of
2256    /// one proxied tool call. NEVER blocks — a bounded-channel
2257    /// `try_send` that drops and counts on full.
2258    pub fn offer_mcp_tool_call(&self, rec: McpToolCallRecord) {
2259        if self.mcp_tx.try_send(rec).is_err() {
2260            let n = self.mcp_dropped.fetch_add(1, Ordering::Relaxed);
2261            if n.is_multiple_of(4096) {
2262                tracing::warn!(
2263                    code = "analytics_mcp_channel_full",
2264                    total_dropped = n + 1,
2265                    "mcp_tool_calls channel full; dropping records (never blocking the dataplane)"
2266                );
2267            }
2268        }
2269    }
2270
2271    /// MCP tool call records accepted but dropped (channel full).
2272    /// The honest loss counter for the DW-087 path.
2273    pub fn dropped_mcp_tool_call_records(&self) -> u64 {
2274        self.mcp_dropped.load(Ordering::Relaxed)
2275    }
2276}
2277
2278/// The M1 extension contract (feature analysis 11.4): the embedded
2279/// store IS an `extensions::analytics::AnalyticsSink`. The trait's
2280/// failure model allows perpetual `Ok` — "accepted", not "persisted" —
2281/// which is exactly the drop-and-count policy here (a full channel is
2282/// the sink's own overload to absorb, not the caller's problem).
2283#[async_trait::async_trait]
2284impl crate::extensions::analytics::AnalyticsSink for EmbeddedAnalytics {
2285    async fn record(
2286        &self,
2287        event: crate::extensions::analytics::Event,
2288    ) -> Result<(), crate::extensions::ExtensionsError> {
2289        self.offer(RawRecord::from_event(&event));
2290        Ok(())
2291    }
2292}