dwara_core/ai/logging.rs
1//! AI prompt/response logging engine (DW-081): the per-generation
2//! compiled logging config plus redactor.
3//!
4//! Wraps the `ai.logging` config block and its compiled [`Redactor`]
5//! into one immutable engine stored on the dataplane behind an
6//! ArcSwap and swapped on reload. The capture hook in
7//! `dataplane::ai_proxy` consults this engine to decide whether to
8//! capture a request, apply sampling, and redact before storage.
9//!
10//! # Sampling
11//!
12//! The sample decision is DETERMINISTIC per request: a hash of the
13//! request id maps to a 0.0..=1.0 value, and the request is captured
14//! when that value is below `sample_rate`. A re-send with the same
15//! request id lands on the same decision (reproducible), and the
16//! distribution is uniform across request ids.
17//!
18//! # Dependency direction
19//!
20//! `ai` depends on `config` only (see `scripts/check_deps.py`); this
21//! module reads `config::ai::AiConfig` and nothing else. The
22//! analytics RECORD DTO lives in `analytics` (a plain struct, not
23//! importing `ai`) — the dataplane converts at the call site.
24
25use crate::ai::redaction::Redactor;
26use crate::config::ai::AiConfig;
27use std::sync::Arc;
28
29/// The per-generation compiled logging engine (DW-081): the enabled
30/// flag, sampling rate, retention window, and compiled redactor. Built
31/// at dataplane refresh from the published config; immutable once
32/// built. Stored on the dataplane behind an ArcSwap and swapped on
33/// reload, so a logging change applies to the next request with no
34/// restart.
35#[derive(Debug, Clone)]
36pub struct AiLoggingEngine {
37 enabled: bool,
38 sample_rate: f64,
39 retention_secs: u64,
40 redactor: Arc<Redactor>,
41}
42
43impl AiLoggingEngine {
44 /// Compile from the `ai:` config block. `None` when the block is
45 /// absent or the `logging` sub-block is absent (no capture —
46 /// privacy-first). When present, the `enabled` flag governs
47 /// capture; the redactor is always compiled (a disabled engine
48 /// still carries one so a per-consumer `Some(true)` override can
49 /// flip capture on without a recompile).
50 pub fn compile(cfg: Option<&AiConfig>) -> Option<Self> {
51 let cfg = cfg?;
52 let logging = cfg.logging.as_ref()?;
53 let redactor = Arc::new(Redactor::compile(&logging.redaction));
54 Some(AiLoggingEngine {
55 enabled: logging.enabled,
56 sample_rate: logging.sample_rate,
57 retention_secs: logging.retention_secs,
58 redactor,
59 })
60 }
61
62 /// Whether global capture is enabled.
63 pub fn enabled(&self) -> bool {
64 self.enabled
65 }
66
67 /// The configured sampling rate (0.0..=1.0).
68 pub fn sample_rate(&self) -> f64 {
69 self.sample_rate
70 }
71
72 /// The configured retention window in seconds.
73 pub fn retention_secs(&self) -> u64 {
74 self.retention_secs
75 }
76
77 /// The compiled redactor.
78 pub fn redactor(&self) -> &Redactor {
79 &self.redactor
80 }
81
82 /// Resolve whether capture is enabled for a given consumer,
83 /// applying the per-consumer override. `consumer_override` is the
84 /// consumer's `ai_logging` field: None inherits the global
85 /// setting; Some(b) overrides it.
86 pub fn capture_for(&self, consumer_override: Option<bool>) -> bool {
87 match consumer_override {
88 Some(b) => b,
89 None => self.enabled,
90 }
91 }
92
93 /// Deterministic sampling decision for a request id. Returns true
94 /// when the request should be captured (the hash of the request id
95 /// maps below `sample_rate`). A rate of 1.0 always captures; 0.0
96 /// never captures.
97 pub fn should_sample(&self, request_id: &str) -> bool {
98 if self.sample_rate >= 1.0 {
99 return true;
100 }
101 if self.sample_rate <= 0.0 {
102 return false;
103 }
104 // FxHash-style mix of the request id bytes, mapped to 0.0..=1.0.
105 let hash = hash_request_id(request_id);
106 let frac = (hash as f64) / (u64::MAX as f64);
107 frac < self.sample_rate
108 }
109}
110
111/// A deterministic hash of the request id for sampling. Uses a simple
112/// multiplicative hash (FNV-1a variant) — the goal is uniform
113/// distribution across request ids, not cryptographic strength.
114fn hash_request_id(s: &str) -> u64 {
115 let mut hash: u64 = 0xcbf29ce484222325;
116 for b in s.as_bytes() {
117 hash ^= *b as u64;
118 hash = hash.wrapping_mul(0x100000001b3);
119 }
120 hash
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn engine(enabled: bool, sample_rate: f64) -> AiLoggingEngine {
128 AiLoggingEngine {
129 enabled,
130 sample_rate,
131 retention_secs: 604_800,
132 redactor: Arc::new(Redactor::inert()),
133 }
134 }
135
136 #[test]
137 fn sample_rate_zero_never_captures() {
138 let e = engine(true, 0.0);
139 assert!(!e.should_sample("req-1"));
140 assert!(!e.should_sample("req-2"));
141 }
142
143 #[test]
144 fn sample_rate_one_always_captures() {
145 let e = engine(true, 1.0);
146 assert!(e.should_sample("req-1"));
147 assert!(e.should_sample("req-2"));
148 }
149
150 #[test]
151 fn sample_rate_is_deterministic() {
152 let e = engine(true, 0.5);
153 // The same id always yields the same decision.
154 let d1 = e.should_sample("req-abc");
155 let d2 = e.should_sample("req-abc");
156 assert_eq!(d1, d2);
157 }
158
159 #[test]
160 fn per_consumer_override_disables() {
161 let e = engine(true, 1.0);
162 assert!(!e.capture_for(Some(false)));
163 }
164
165 #[test]
166 fn per_consumer_override_enables() {
167 let e = engine(false, 1.0);
168 assert!(e.capture_for(Some(true)));
169 }
170
171 #[test]
172 fn per_consumer_none_inherits_global() {
173 let e = engine(true, 1.0);
174 assert!(e.capture_for(None));
175 let e2 = engine(false, 1.0);
176 assert!(!e2.capture_for(None));
177 }
178}