Skip to main content

fraiseql_auth/
monitoring.rs

1//! Authentication monitoring and observability.
2//!
3//! Provides [`AuthEvent`] for structured event logging, [`AuthMetrics`] for
4//! in-process counters, and [`OperationTimer`] for latency measurement.
5use std::time::Instant;
6
7use serde::Serialize;
8use tracing::{Level, info, span, warn};
9
10/// A structured log record for a single authentication event.
11///
12/// Constructed with [`AuthEvent::new`] and populated via builder methods.
13/// Call [`AuthEvent::log`] to emit the record through `tracing`.
14///
15/// # Example
16///
17/// ```rust
18/// use fraiseql_auth::AuthEvent;
19/// let event = AuthEvent::new("login")
20///     .with_user_id("user123".to_string())
21///     .with_provider("google".to_string())
22///     .success(42.5);
23/// event.log();
24/// ```
25#[derive(Debug, Serialize)]
26pub struct AuthEvent {
27    /// Name of the authentication event (e.g., `"login"`, `"token_refresh"`).
28    pub event:       String,
29    /// Optional authenticated user ID associated with this event.
30    pub user_id:     Option<String>,
31    /// OAuth provider name (e.g., `"google"`, `"okta"`).
32    pub provider:    Option<String>,
33    /// Outcome: `"started"`, `"success"`, or `"error"`.
34    pub status:      String,
35    /// Duration of the operation in milliseconds.
36    pub duration_ms: f64,
37    /// Error message if the operation failed.
38    pub error:       Option<String>,
39    /// RFC 3339 timestamp of when this event was created.
40    pub timestamp:   String,
41    /// Optional correlation ID for tracing a request across services.
42    pub request_id:  Option<String>,
43}
44
45impl AuthEvent {
46    /// Create a new event record in the `"started"` state.
47    pub fn new(event: &str) -> Self {
48        Self {
49            event:       event.to_string(),
50            user_id:     None,
51            provider:    None,
52            status:      "started".to_string(),
53            duration_ms: 0.0,
54            error:       None,
55            timestamp:   chrono::Utc::now().to_rfc3339(),
56            request_id:  None,
57        }
58    }
59
60    /// Set the user ID associated with this event.
61    pub fn with_user_id(mut self, user_id: String) -> Self {
62        self.user_id = Some(user_id);
63        self
64    }
65
66    /// Set the OAuth provider name for this event.
67    pub fn with_provider(mut self, provider: String) -> Self {
68        self.provider = Some(provider);
69        self
70    }
71
72    /// Set the request correlation ID for distributed tracing.
73    pub fn with_request_id(mut self, request_id: String) -> Self {
74        self.request_id = Some(request_id);
75        self
76    }
77
78    /// Mark the event as successful and record its duration.
79    pub fn success(mut self, duration_ms: f64) -> Self {
80        self.status = "success".to_string();
81        self.duration_ms = duration_ms;
82        self
83    }
84
85    /// Mark the event as failed, recording the error and duration.
86    pub fn error(mut self, error: String, duration_ms: f64) -> Self {
87        self.status = "error".to_string();
88        self.error = Some(error);
89        self.duration_ms = duration_ms;
90        self
91    }
92
93    /// Emit this event through `tracing` at the appropriate level.
94    ///
95    /// Successful events are logged at `INFO`; errors at `WARN`.
96    /// Events in the `"started"` state are silently dropped.
97    pub fn log(&self) {
98        match self.status.as_str() {
99            "success" => {
100                info!(
101                    event = %self.event,
102                    user_id = ?self.user_id,
103                    provider = ?self.provider,
104                    duration_ms = self.duration_ms,
105                    "Authentication event",
106                );
107            },
108            "error" => {
109                warn!(
110                    event = %self.event,
111                    error = ?self.error,
112                    duration_ms = self.duration_ms,
113                    "Authentication error",
114                );
115            },
116            _ => {},
117        }
118    }
119}
120
121/// In-process counters for authentication operations.
122///
123/// These counters are for lightweight observability within a single process.
124/// For production monitoring, export these values to a metrics system such as
125/// Prometheus.  All fields are plain `u64`; thread-safe mutation requires an
126/// outer `Mutex` or `RwLock`.
127#[derive(Debug, Clone)]
128pub struct AuthMetrics {
129    /// Total number of authentication attempts (successful + failed).
130    pub total_auth_attempts:        u64,
131    /// Number of authentication attempts that succeeded.
132    pub successful_authentications: u64,
133    /// Number of authentication attempts that failed.
134    pub failed_authentications:     u64,
135    /// Number of access tokens issued since startup.
136    pub tokens_issued:              u64,
137    /// Number of access tokens refreshed since startup.
138    pub tokens_refreshed:           u64,
139    /// Number of sessions explicitly revoked since startup.
140    pub sessions_revoked:           u64,
141}
142
143impl AuthMetrics {
144    /// Create a new `AuthMetrics` with all counters initialized to zero.
145    pub const fn new() -> Self {
146        Self {
147            total_auth_attempts:        0,
148            successful_authentications: 0,
149            failed_authentications:     0,
150            tokens_issued:              0,
151            tokens_refreshed:           0,
152            sessions_revoked:           0,
153        }
154    }
155
156    /// Increment the total authentication attempts counter.
157    pub const fn record_attempt(&mut self) {
158        self.total_auth_attempts += 1;
159    }
160
161    /// Increment the successful authentications counter.
162    pub const fn record_success(&mut self) {
163        self.successful_authentications += 1;
164    }
165
166    /// Increment the failed authentications counter.
167    pub const fn record_failure(&mut self) {
168        self.failed_authentications += 1;
169    }
170
171    /// Increment the tokens issued counter.
172    pub const fn record_token_issued(&mut self) {
173        self.tokens_issued += 1;
174    }
175
176    /// Increment the tokens refreshed counter.
177    pub const fn record_token_refreshed(&mut self) {
178        self.tokens_refreshed += 1;
179    }
180
181    /// Increment the sessions revoked counter.
182    pub const fn record_session_revoked(&mut self) {
183        self.sessions_revoked += 1;
184    }
185
186    /// Return the success rate as a percentage (0–100).
187    ///
188    /// Returns `0.0` when no attempts have been recorded yet.
189    pub fn success_rate(&self) -> f64 {
190        if self.total_auth_attempts == 0 {
191            0.0
192        } else {
193            #[allow(clippy::cast_precision_loss)] // Reason: acceptable precision for metrics/timing
194            let result = (self.successful_authentications as f64)
195                / (self.total_auth_attempts as f64)
196                * 100.0;
197            result
198        }
199    }
200}
201
202impl Default for AuthMetrics {
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208/// A wall-clock timer for measuring the duration of authentication operations.
209///
210/// The timer starts immediately on construction via [`OperationTimer::start`].
211/// Call [`OperationTimer::finish`] to log the elapsed time and discard the timer,
212/// or read [`OperationTimer::elapsed_ms`] to sample without consuming.
213pub struct OperationTimer {
214    start:     Instant,
215    operation: String,
216}
217
218impl OperationTimer {
219    /// Start timing `operation` and open a tracing span at `DEBUG` level.
220    pub fn start(operation: &str) -> Self {
221        let span = span!(Level::DEBUG, "operation", %operation);
222        let _guard = span.enter();
223
224        Self {
225            start:     Instant::now(),
226            operation: operation.to_string(),
227        }
228    }
229
230    /// Return the elapsed time in milliseconds since this timer was started.
231    pub fn elapsed_ms(&self) -> f64 {
232        self.start.elapsed().as_secs_f64() * 1000.0
233    }
234
235    /// Log the completed operation at `INFO` level with its elapsed duration.
236    pub fn finish(self) {
237        let elapsed = self.elapsed_ms();
238        info!(
239            operation = %self.operation,
240            duration_ms = elapsed,
241            "Operation completed",
242        );
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    #[allow(clippy::wildcard_imports)]
249    // Reason: test module — wildcard keeps test boilerplate minimal
250    use super::*;
251
252    #[test]
253    #[allow(clippy::float_cmp)] // Reason: acceptable precision for metrics/timing — values set directly from literals
254    fn test_auth_event_builder() {
255        let event = AuthEvent::new("login")
256            .with_user_id("user123".to_string())
257            .with_provider("google".to_string())
258            .success(50.0);
259
260        assert_eq!(event.event, "login");
261        assert_eq!(event.user_id, Some("user123".to_string()));
262        assert_eq!(event.provider, Some("google".to_string()));
263        assert_eq!(event.status, "success");
264        assert_eq!(event.duration_ms, 50.0);
265    }
266
267    #[test]
268    #[allow(clippy::float_cmp)] // Reason: acceptable precision for metrics/timing — values set directly from literals
269    fn test_auth_metrics() {
270        let mut metrics = AuthMetrics::new();
271
272        metrics.record_attempt();
273        metrics.record_attempt();
274        metrics.record_success();
275        metrics.record_failure();
276
277        assert_eq!(metrics.total_auth_attempts, 2);
278        assert_eq!(metrics.successful_authentications, 1);
279        assert_eq!(metrics.failed_authentications, 1);
280        assert_eq!(metrics.success_rate(), 50.0);
281    }
282
283    #[test]
284    #[allow(clippy::float_cmp)] // Reason: acceptable precision for metrics/timing — values set directly from literals
285    fn test_auth_metrics_success_rate() {
286        let mut metrics = AuthMetrics::new();
287
288        // 100% success rate
289        for _ in 0..10 {
290            metrics.record_attempt();
291            metrics.record_success();
292        }
293
294        assert_eq!(metrics.success_rate(), 100.0);
295
296        // Drop to 50%
297        metrics.record_attempt();
298        metrics.record_failure();
299
300        assert!((metrics.success_rate() - 90.91).abs() < 0.1); // ~90.91%
301    }
302
303    #[test]
304    fn test_operation_timer() {
305        let timer = OperationTimer::start("test_op");
306        let elapsed = timer.elapsed_ms();
307        assert!(elapsed >= 0.0);
308        assert!(elapsed < 1000.0);
309    }
310}