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}