tower_http_cache/
logging.rs

1//! ML-ready structured logging for cache operations.
2//!
3//! This module provides comprehensive structured logging suitable for
4//! machine learning training and analysis. All cache operations emit
5//! JSON-formatted logs with rich metadata for correlation and analysis.
6
7use crate::request_id::RequestId;
8use http::{Method, StatusCode, Uri, Version};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use sha2::{Digest, Sha256};
12use std::time::{Duration, SystemTime};
13
14/// Configuration for ML-ready structured logging.
15#[derive(Debug, Clone)]
16pub struct MLLoggingConfig {
17    /// Enable ML-ready structured logging
18    pub enabled: bool,
19
20    /// Sample rate (1.0 = all requests, 0.1 = 10%)
21    pub sample_rate: f64,
22
23    /// Hash cache keys for privacy (recommended for production)
24    pub hash_keys: bool,
25
26    /// Target for structured logs (defaults to "tower_http_cache::ml")
27    pub target: String,
28}
29
30impl Default for MLLoggingConfig {
31    fn default() -> Self {
32        Self {
33            enabled: false,
34            sample_rate: 1.0,
35            hash_keys: true,
36            target: "tower_http_cache::ml".to_string(),
37        }
38    }
39}
40
41impl MLLoggingConfig {
42    /// Creates a new ML logging configuration with default settings.
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Enables ML logging.
48    pub fn with_enabled(mut self, enabled: bool) -> Self {
49        self.enabled = enabled;
50        self
51    }
52
53    /// Sets the sample rate (0.0 to 1.0).
54    pub fn with_sample_rate(mut self, rate: f64) -> Self {
55        self.sample_rate = rate.clamp(0.0, 1.0);
56        self
57    }
58
59    /// Enables or disables key hashing.
60    pub fn with_hash_keys(mut self, hash: bool) -> Self {
61        self.hash_keys = hash;
62        self
63    }
64
65    /// Sets a custom logging target.
66    pub fn with_target(mut self, target: impl Into<String>) -> Self {
67        self.target = target.into();
68        self
69    }
70
71    /// Checks if this request should be logged based on sampling rate.
72    pub fn should_sample(&self) -> bool {
73        if !self.enabled {
74            return false;
75        }
76        if self.sample_rate >= 1.0 {
77            return true;
78        }
79        use std::collections::hash_map::RandomState;
80        use std::hash::BuildHasher;
81        let hasher = RandomState::new();
82
83        let random = (hasher.hash_one(std::time::SystemTime::now()) as f64) / (u64::MAX as f64);
84        random < self.sample_rate
85    }
86}
87
88/// Types of cache events that can be logged.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum CacheEventType {
92    /// Cache lookup hit (fresh entry)
93    Hit,
94    /// Cache lookup miss
95    Miss,
96    /// Stale entry served
97    StaleHit,
98    /// Cache entry stored
99    Store,
100    /// Cache entry invalidated
101    Invalidate,
102    /// Tag-based invalidation
103    TagInvalidate,
104    /// Multi-tier cache promotion
105    TierPromote,
106    /// Admin API access
107    AdminAccess,
108}
109
110/// Structured cache event for ML training.
111#[derive(Debug, Clone)]
112pub struct CacheEvent {
113    /// Timestamp of the event
114    pub timestamp: SystemTime,
115
116    /// Type of cache event
117    pub event_type: CacheEventType,
118
119    /// Request ID for correlation
120    pub request_id: RequestId,
121
122    /// Cache key (may be hashed)
123    pub key: String,
124
125    /// Request method
126    pub method: Option<Method>,
127
128    /// Request URI
129    pub uri: Option<Uri>,
130
131    /// HTTP version
132    pub version: Option<Version>,
133
134    /// Response status code
135    pub status: Option<StatusCode>,
136
137    /// Whether this was a cache hit
138    pub hit: bool,
139
140    /// Operation latency in microseconds
141    pub latency_us: Option<u64>,
142
143    /// Response size in bytes
144    pub size_bytes: Option<usize>,
145
146    /// TTL in seconds
147    pub ttl_seconds: Option<u64>,
148
149    /// Cache tags associated with this entry
150    pub tags: Option<Vec<String>>,
151
152    /// Tier information (l1, l2, or None)
153    pub tier: Option<String>,
154
155    /// Whether entry was promoted between tiers
156    pub promoted: bool,
157
158    /// Additional metadata
159    pub metadata: serde_json::Value,
160}
161
162impl CacheEvent {
163    /// Creates a new cache event.
164    pub fn new(event_type: CacheEventType, request_id: RequestId, key: String) -> Self {
165        Self {
166            timestamp: SystemTime::now(),
167            event_type,
168            request_id,
169            key,
170            method: None,
171            uri: None,
172            version: None,
173            status: None,
174            hit: false,
175            latency_us: None,
176            size_bytes: None,
177            ttl_seconds: None,
178            tags: None,
179            tier: None,
180            promoted: false,
181            metadata: json!({}),
182        }
183    }
184
185    /// Sets the HTTP method.
186    pub fn with_method(mut self, method: Method) -> Self {
187        self.method = Some(method);
188        self
189    }
190
191    /// Sets the request URI.
192    pub fn with_uri(mut self, uri: Uri) -> Self {
193        self.uri = Some(uri);
194        self
195    }
196
197    /// Sets the HTTP version.
198    pub fn with_version(mut self, version: Version) -> Self {
199        self.version = Some(version);
200        self
201    }
202
203    /// Sets the response status.
204    pub fn with_status(mut self, status: StatusCode) -> Self {
205        self.status = Some(status);
206        self
207    }
208
209    /// Sets whether this was a cache hit.
210    pub fn with_hit(mut self, hit: bool) -> Self {
211        self.hit = hit;
212        self
213    }
214
215    /// Sets the operation latency.
216    pub fn with_latency(mut self, latency: Duration) -> Self {
217        self.latency_us = Some(latency.as_micros() as u64);
218        self
219    }
220
221    /// Sets the response size.
222    pub fn with_size(mut self, size: usize) -> Self {
223        self.size_bytes = Some(size);
224        self
225    }
226
227    /// Sets the TTL.
228    pub fn with_ttl(mut self, ttl: Duration) -> Self {
229        self.ttl_seconds = Some(ttl.as_secs());
230        self
231    }
232
233    /// Sets the cache tags.
234    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
235        self.tags = Some(tags);
236        self
237    }
238
239    /// Sets the tier information.
240    pub fn with_tier(mut self, tier: impl Into<String>) -> Self {
241        self.tier = Some(tier.into());
242        self
243    }
244
245    /// Sets whether the entry was promoted.
246    pub fn with_promoted(mut self, promoted: bool) -> Self {
247        self.promoted = promoted;
248        self
249    }
250
251    /// Adds custom metadata.
252    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
253        self.metadata = metadata;
254        self
255    }
256
257    /// Logs this event using the provided configuration.
258    pub fn log(&self, config: &MLLoggingConfig) {
259        if !config.should_sample() {
260            return;
261        }
262
263        let key = if config.hash_keys {
264            hash_key(&self.key)
265        } else {
266            self.key.clone()
267        };
268
269        let timestamp = self
270            .timestamp
271            .duration_since(SystemTime::UNIX_EPOCH)
272            .unwrap_or_default();
273
274        let log_data = json!({
275            "timestamp": format!("{}.{:03}Z",
276                chrono::DateTime::<chrono::Utc>::from(self.timestamp)
277                    .format("%Y-%m-%dT%H:%M:%S"),
278                timestamp.subsec_millis()
279            ),
280            "level": "info",
281            "event": format!("{:?}", self.event_type).to_lowercase(),
282            "request_id": self.request_id.as_str(),
283            "key": key,
284            "method": self.method.as_ref().map(|m| m.as_str()),
285            "uri": self.uri.as_ref().map(|u| u.to_string()),
286            "version": self.version.as_ref().map(|v| format!("{:?}", v)),
287            "status": self.status.as_ref().map(|s| s.as_u16()),
288            "hit": self.hit,
289            "latency_us": self.latency_us,
290            "size_bytes": self.size_bytes,
291            "ttl_seconds": self.ttl_seconds,
292            "tags": self.tags,
293            "tier": self.tier,
294            "promoted": self.promoted,
295            "metadata": self.metadata,
296        });
297
298        #[cfg(feature = "tracing")]
299        {
300            // Use fixed target for tracing, but include the configured target in the log data
301            tracing::info!(
302                target: "tower_http_cache::ml",
303                event = %log_data
304            );
305        }
306
307        #[cfg(not(feature = "tracing"))]
308        {
309            // Fallback to println for non-tracing builds
310            let _ = config; // suppress warning
311            println!("{}", log_data);
312        }
313    }
314}
315
316/// Hashes a cache key using SHA-256 for privacy.
317pub fn hash_key(key: &str) -> String {
318    let mut hasher = Sha256::new();
319    hasher.update(key.as_bytes());
320    let result = hasher.finalize();
321    hex::encode(result)
322}
323
324/// Helper to log a simple cache operation.
325pub fn log_cache_operation(
326    config: &MLLoggingConfig,
327    event_type: CacheEventType,
328    request_id: RequestId,
329    key: String,
330) {
331    if !config.enabled {
332        return;
333    }
334
335    let event = CacheEvent::new(event_type, request_id, key);
336    event.log(config);
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn ml_logging_config_default() {
345        let config = MLLoggingConfig::default();
346        assert!(!config.enabled);
347        assert_eq!(config.sample_rate, 1.0);
348        assert!(config.hash_keys);
349    }
350
351    #[test]
352    fn ml_logging_config_builder() {
353        let config = MLLoggingConfig::new()
354            .with_enabled(true)
355            .with_sample_rate(0.5)
356            .with_hash_keys(false)
357            .with_target("custom::target");
358
359        assert!(config.enabled);
360        assert_eq!(config.sample_rate, 0.5);
361        assert!(!config.hash_keys);
362        assert_eq!(config.target, "custom::target");
363    }
364
365    #[test]
366    fn sample_rate_clamped() {
367        let config = MLLoggingConfig::new().with_sample_rate(1.5);
368        assert_eq!(config.sample_rate, 1.0);
369
370        let config = MLLoggingConfig::new().with_sample_rate(-0.5);
371        assert_eq!(config.sample_rate, 0.0);
372    }
373
374    #[test]
375    fn should_sample_when_disabled() {
376        let config = MLLoggingConfig::new().with_enabled(false);
377        assert!(!config.should_sample());
378    }
379
380    #[test]
381    fn should_sample_when_rate_is_one() {
382        let config = MLLoggingConfig::new()
383            .with_enabled(true)
384            .with_sample_rate(1.0);
385        assert!(config.should_sample());
386    }
387
388    #[test]
389    fn hash_key_consistent() {
390        let key = "/api/users/123";
391        let hash1 = hash_key(key);
392        let hash2 = hash_key(key);
393        assert_eq!(hash1, hash2);
394        assert_ne!(hash1, key);
395        assert_eq!(hash1.len(), 64); // SHA-256 produces 64 hex chars
396    }
397
398    #[test]
399    fn cache_event_builder() {
400        let request_id = RequestId::new();
401        let event = CacheEvent::new(CacheEventType::Hit, request_id.clone(), "/test".to_string())
402            .with_method(Method::GET)
403            .with_status(StatusCode::OK)
404            .with_hit(true)
405            .with_latency(Duration::from_micros(150))
406            .with_size(1024)
407            .with_ttl(Duration::from_secs(300))
408            .with_tags(vec!["user:123".to_string()])
409            .with_tier("l1")
410            .with_promoted(false);
411
412        assert_eq!(event.method, Some(Method::GET));
413        assert_eq!(event.status, Some(StatusCode::OK));
414        assert!(event.hit);
415        assert_eq!(event.latency_us, Some(150));
416        assert_eq!(event.size_bytes, Some(1024));
417        assert_eq!(event.ttl_seconds, Some(300));
418        assert_eq!(event.tags, Some(vec!["user:123".to_string()]));
419        assert_eq!(event.tier, Some("l1".to_string()));
420        assert!(!event.promoted);
421    }
422
423    #[test]
424    fn cache_event_log_disabled() {
425        let config = MLLoggingConfig::new().with_enabled(false);
426        let request_id = RequestId::new();
427        let event = CacheEvent::new(CacheEventType::Hit, request_id, "/test".to_string());
428
429        // Should not panic when logging is disabled
430        event.log(&config);
431    }
432
433    #[test]
434    fn cache_event_log_with_hashing() {
435        let config = MLLoggingConfig::new()
436            .with_enabled(true)
437            .with_hash_keys(true);
438        let request_id = RequestId::new();
439        let event = CacheEvent::new(CacheEventType::Hit, request_id, "/api/secret".to_string());
440
441        // Should not panic when logging with hashing
442        event.log(&config);
443    }
444
445    #[test]
446    fn log_cache_operation_helper() {
447        let config = MLLoggingConfig::new().with_enabled(true);
448        let request_id = RequestId::new();
449
450        // Should not panic
451        log_cache_operation(
452            &config,
453            CacheEventType::Miss,
454            request_id,
455            "/test".to_string(),
456        );
457    }
458}