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(¤t))
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}