Skip to main content

forge_core/config/
signals.rs

1//! Signals configuration for product analytics and diagnostics.
2
3use serde::{Deserialize, Serialize};
4
5/// Signals configuration for built-in product analytics and frontend diagnostics.
6///
7/// Captures user behavior, acquisition channels, feature usage, and frontend
8/// errors without cookies or persistent client-side state (GDPR-compliant).
9///
10/// ```toml
11/// [signals]
12/// enabled = true
13/// auto_capture = true
14/// diagnostics = true
15/// session_timeout_mins = 30
16/// retention_days = 90
17/// ```
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SignalsConfig {
20    /// Enable the signals pipeline (event ingestion, auto-capture, dashboards).
21    #[serde(default = "default_true")]
22    pub enabled: bool,
23
24    /// Auto-capture RPC calls as events without user code.
25    #[serde(default = "default_true")]
26    pub auto_capture: bool,
27
28    /// Capture frontend errors, failed RPCs, and breadcrumbs for reproduction.
29    #[serde(default = "default_true")]
30    pub diagnostics: bool,
31
32    /// Inactivity timeout before closing a session (minutes).
33    #[serde(default = "default_session_timeout_mins")]
34    pub session_timeout_mins: u32,
35
36    /// Days to retain event data before partition cleanup.
37    #[serde(default = "default_retention_days")]
38    pub retention_days: u32,
39
40    /// Hash client IP + UA into a daily-rotating visitor ID instead of storing raw IP.
41    #[serde(default)]
42    pub anonymize_ip: bool,
43
44    /// Max events per batch INSERT.
45    #[serde(default = "default_batch_size")]
46    pub batch_size: usize,
47
48    /// Max milliseconds between flushes of the event buffer.
49    #[serde(default = "default_flush_interval_ms")]
50    pub flush_interval_ms: u64,
51
52    /// Function names to exclude from auto-capture (exact match).
53    #[serde(default)]
54    pub excluded_functions: Vec<String>,
55
56    /// Tag bot traffic via UA detection (visible in dashboard filter).
57    #[serde(default = "default_true")]
58    pub bot_detection: bool,
59
60    /// Optional path to a MaxMind MMDB file (e.g. GeoLite2-City.mmdb) for
61    /// city-level resolution. When omitted, the embedded DB-IP Country Lite
62    /// database provides country-level resolution with zero configuration.
63    #[serde(default)]
64    pub geoip_db_path: Option<String>,
65}
66
67impl Default for SignalsConfig {
68    fn default() -> Self {
69        Self {
70            enabled: true,
71            auto_capture: true,
72            diagnostics: true,
73            session_timeout_mins: default_session_timeout_mins(),
74            retention_days: default_retention_days(),
75            anonymize_ip: false,
76            batch_size: default_batch_size(),
77            flush_interval_ms: default_flush_interval_ms(),
78            excluded_functions: Vec::new(),
79            bot_detection: true,
80            geoip_db_path: None,
81        }
82    }
83}
84
85use super::default_true;
86
87fn default_session_timeout_mins() -> u32 {
88    30
89}
90
91fn default_retention_days() -> u32 {
92    90
93}
94
95fn default_batch_size() -> usize {
96    100
97}
98
99fn default_flush_interval_ms() -> u64 {
100    5000
101}
102
103#[cfg(test)]
104#[allow(clippy::unwrap_used)]
105mod tests {
106    use super::*;
107
108    #[tokio::test]
109    async fn default_config_has_correct_values() {
110        let config = SignalsConfig::default();
111        assert!(config.enabled);
112        assert!(config.auto_capture);
113        assert!(config.diagnostics);
114        assert_eq!(config.session_timeout_mins, 30);
115        assert_eq!(config.retention_days, 90);
116        assert!(!config.anonymize_ip);
117        assert_eq!(config.batch_size, 100);
118        assert_eq!(config.flush_interval_ms, 5000);
119        assert!(config.excluded_functions.is_empty());
120        assert!(config.bot_detection);
121    }
122
123    #[tokio::test]
124    async fn deserializes_empty_toml() {
125        #[derive(Deserialize)]
126        struct Wrapper {
127            #[serde(default)]
128            signals: SignalsConfig,
129        }
130
131        let from_empty: SignalsConfig = toml::from_str("").unwrap();
132        let from_table: Wrapper = toml::from_str("[signals]").unwrap();
133
134        for config in [from_empty, from_table.signals] {
135            assert!(config.enabled);
136            assert!(config.auto_capture);
137            assert!(config.diagnostics);
138            assert_eq!(config.session_timeout_mins, 30);
139            assert_eq!(config.retention_days, 90);
140            assert!(!config.anonymize_ip);
141            assert_eq!(config.batch_size, 100);
142            assert_eq!(config.flush_interval_ms, 5000);
143            assert!(config.excluded_functions.is_empty());
144            assert!(config.bot_detection);
145        }
146    }
147
148    #[tokio::test]
149    async fn partial_override_preserves_defaults() {
150        let config: SignalsConfig = toml::from_str("enabled = false").unwrap();
151        assert!(!config.enabled);
152        assert!(config.auto_capture);
153        assert!(config.diagnostics);
154        assert_eq!(config.session_timeout_mins, 30);
155        assert_eq!(config.retention_days, 90);
156        assert!(!config.anonymize_ip);
157        assert_eq!(config.batch_size, 100);
158        assert_eq!(config.flush_interval_ms, 5000);
159        assert!(config.excluded_functions.is_empty());
160        assert!(config.bot_detection);
161    }
162
163    #[tokio::test]
164    async fn excluded_functions_from_toml() {
165        let config: SignalsConfig =
166            toml::from_str(r#"excluded_functions = ["health_check", "readiness"]"#).unwrap();
167        assert_eq!(config.excluded_functions, vec!["health_check", "readiness"]);
168    }
169
170    #[tokio::test]
171    async fn all_fields_overridden() {
172        let toml_str = r#"
173            enabled = false
174            auto_capture = false
175            diagnostics = false
176            session_timeout_mins = 60
177            retention_days = 30
178            anonymize_ip = true
179            batch_size = 500
180            flush_interval_ms = 10000
181            excluded_functions = ["ping"]
182            bot_detection = false
183        "#;
184        let config: SignalsConfig = toml::from_str(toml_str).unwrap();
185        assert!(!config.enabled);
186        assert!(!config.auto_capture);
187        assert!(!config.diagnostics);
188        assert_eq!(config.session_timeout_mins, 60);
189        assert_eq!(config.retention_days, 30);
190        assert!(config.anonymize_ip);
191        assert_eq!(config.batch_size, 500);
192        assert_eq!(config.flush_interval_ms, 10000);
193        assert_eq!(config.excluded_functions, vec!["ping"]);
194        assert!(!config.bot_detection);
195    }
196}