Skip to main content

forge_core/config/
signals.rs

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