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}