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 #[must_use]
48 pub fn new(event: &str) -> Self {
49 Self {
50 event: event.to_string(),
51 user_id: None,
52 provider: None,
53 status: "started".to_string(),
54 duration_ms: 0.0,
55 error: None,
56 timestamp: chrono::Utc::now().to_rfc3339(),
57 request_id: None,
58 }
59 }
60
61 /// Set the user ID associated with this event.
62 #[must_use]
63 pub fn with_user_id(mut self, user_id: String) -> Self {
64 self.user_id = Some(user_id);
65 self
66 }
67
68 /// Set the OAuth provider name for this event.
69 #[must_use]
70 pub fn with_provider(mut self, provider: String) -> Self {
71 self.provider = Some(provider);
72 self
73 }
74
75 /// Set the request correlation ID for distributed tracing.
76 #[must_use]
77 pub fn with_request_id(mut self, request_id: String) -> Self {
78 self.request_id = Some(request_id);
79 self
80 }
81
82 /// Mark the event as successful and record its duration.
83 #[must_use]
84 pub fn success(mut self, duration_ms: f64) -> Self {
85 self.status = "success".to_string();
86 self.duration_ms = duration_ms;
87 self
88 }
89
90 /// Mark the event as failed, recording the error and duration.
91 #[must_use]
92 pub fn error(mut self, error: String, duration_ms: f64) -> Self {
93 self.status = "error".to_string();
94 self.error = Some(error);
95 self.duration_ms = duration_ms;
96 self
97 }
98
99 /// Emit this event through `tracing` at the appropriate level.
100 ///
101 /// Successful events are logged at `INFO`; errors at `WARN`.
102 /// Events in the `"started"` state are silently dropped.
103 pub fn log(&self) {
104 match self.status.as_str() {
105 "success" => {
106 info!(
107 event = %self.event,
108 user_id = ?self.user_id,
109 provider = ?self.provider,
110 duration_ms = self.duration_ms,
111 "Authentication event",
112 );
113 },
114 "error" => {
115 warn!(
116 event = %self.event,
117 error = ?self.error,
118 duration_ms = self.duration_ms,
119 "Authentication error",
120 );
121 },
122 _ => {},
123 }
124 }
125}
126
127/// In-process counters for authentication operations.
128///
129/// These counters are for lightweight observability within a single process.
130/// For production monitoring, export these values to a metrics system such as
131/// Prometheus. All fields are plain `u64`; thread-safe mutation requires an
132/// outer `Mutex` or `RwLock`.
133#[derive(Debug, Clone)]
134pub struct AuthMetrics {
135 /// Total number of authentication attempts (successful + failed).
136 pub total_auth_attempts: u64,
137 /// Number of authentication attempts that succeeded.
138 pub successful_authentications: u64,
139 /// Number of authentication attempts that failed.
140 pub failed_authentications: u64,
141 /// Number of access tokens issued since startup.
142 pub tokens_issued: u64,
143 /// Number of access tokens refreshed since startup.
144 pub tokens_refreshed: u64,
145 /// Number of sessions explicitly revoked since startup.
146 pub sessions_revoked: u64,
147}
148
149impl AuthMetrics {
150 /// Create a new `AuthMetrics` with all counters initialized to zero.
151 #[must_use]
152 pub const fn new() -> Self {
153 Self {
154 total_auth_attempts: 0,
155 successful_authentications: 0,
156 failed_authentications: 0,
157 tokens_issued: 0,
158 tokens_refreshed: 0,
159 sessions_revoked: 0,
160 }
161 }
162
163 /// Increment the total authentication attempts counter.
164 pub const fn record_attempt(&mut self) {
165 self.total_auth_attempts += 1;
166 }
167
168 /// Increment the successful authentications counter.
169 pub const fn record_success(&mut self) {
170 self.successful_authentications += 1;
171 }
172
173 /// Increment the failed authentications counter.
174 pub const fn record_failure(&mut self) {
175 self.failed_authentications += 1;
176 }
177
178 /// Increment the tokens issued counter.
179 pub const fn record_token_issued(&mut self) {
180 self.tokens_issued += 1;
181 }
182
183 /// Increment the tokens refreshed counter.
184 pub const fn record_token_refreshed(&mut self) {
185 self.tokens_refreshed += 1;
186 }
187
188 /// Increment the sessions revoked counter.
189 pub const fn record_session_revoked(&mut self) {
190 self.sessions_revoked += 1;
191 }
192
193 /// Return the success rate as a percentage (0–100).
194 ///
195 /// Returns `0.0` when no attempts have been recorded yet.
196 #[must_use]
197 pub fn success_rate(&self) -> f64 {
198 if self.total_auth_attempts == 0 {
199 0.0
200 } else {
201 #[allow(clippy::cast_precision_loss)] // Reason: acceptable precision for metrics/timing
202 let result = (self.successful_authentications as f64)
203 / (self.total_auth_attempts as f64)
204 * 100.0;
205 result
206 }
207 }
208}
209
210impl Default for AuthMetrics {
211 fn default() -> Self {
212 Self::new()
213 }
214}
215
216/// A wall-clock timer for measuring the duration of authentication operations.
217///
218/// The timer starts immediately on construction via [`OperationTimer::start`].
219/// Call [`OperationTimer::finish`] to log the elapsed time and discard the timer,
220/// or read [`OperationTimer::elapsed_ms`] to sample without consuming.
221pub struct OperationTimer {
222 start: Instant,
223 operation: String,
224}
225
226impl OperationTimer {
227 /// Start timing `operation` and open a tracing span at `DEBUG` level.
228 pub fn start(operation: &str) -> Self {
229 let span = span!(Level::DEBUG, "operation", %operation);
230 let _guard = span.enter();
231
232 Self {
233 start: Instant::now(),
234 operation: operation.to_string(),
235 }
236 }
237
238 /// Return the elapsed time in milliseconds since this timer was started.
239 #[must_use]
240 pub fn elapsed_ms(&self) -> f64 {
241 self.start.elapsed().as_secs_f64() * 1000.0
242 }
243
244 /// Log the completed operation at `INFO` level with its elapsed duration.
245 pub fn finish(self) {
246 let elapsed = self.elapsed_ms();
247 info!(
248 operation = %self.operation,
249 duration_ms = elapsed,
250 "Operation completed",
251 );
252 }
253}