Skip to content
Clone

feat(auth): add metrics module and handler middleware

zhenyi committed
parent 278625c commit e0a1256 Browse files

3 files changed

+594 −0

File tree 3
Added lib/auth/handler/middleware/metrics.rs View lib/auth/handler/middleware/metrics.rs
@@ -0,0 +1,53 @@
1//! HTTP-level metrics middleware for the `auth` router.
2//!
3//! Records `auth_http_requests_total` and `auth_http_request_duration_seconds`
4//! with `route` (via `MatchedPath`, so path params stay low-cardinality),
5//! `method`, and `status_class` (2xx/4xx/5xx/other).
6
7use std::time::Instant;
8
9use axum::{
10 extract::{MatchedPath, Request},
11 middleware::Next,
12 response::Response,
13};
14use metrics::{counter, histogram};
15
16pub async fn metrics_layer(req: Request, next: Next) -> Response {
17 crate::metrics::ensure_described();
18
19 let route = req
20 .extensions()
21 .get::<MatchedPath>()
22 .map(|m| m.as_str().to_owned())
23 .unwrap_or_else(|| "unknown".to_owned());
24 let method = req.method().as_str().to_owned();
25 let start = Instant::now();
26
27 let resp = next.run(req).await;
28
29 let status_class = match resp.status().as_u16() / 100 {
30 2 => "2xx",
31 3 => "3xx",
32 4 => "4xx",
33 5 => "5xx",
34 _ => "other",
35 };
36
37 counter!(
38 "auth_http_requests_total",
39 "route" => route.clone(),
40 "method" => method.clone(),
41 "status_class" => status_class
42 )
43 .increment(1);
44 histogram!(
45 "auth_http_request_duration_seconds",
46 "route" => route,
47 "method" => method,
48 "status_class" => status_class
49 )
50 .record(start.elapsed().as_secs_f64());
51
52 resp
53}
No newline at end of file
Added lib/auth/handler/middleware/mod.rs View lib/auth/handler/middleware/mod.rs
@@ -0,0 +1 @@
1pub(crate) mod metrics;
No newline at end of file
Added lib/auth/metrics.rs View lib/auth/metrics.rs
@@ -0,0 +1,540 @@
1//! Observability facade for the `auth` crate.
2//!
3//! All metric emissions go through the [`metrics`] facade and are no-ops when
4//! no recorder is installed. `lib/obs` (or any host binary) is responsible for
5//! installing a recorder; this module never touches one directly.
6//!
7//! Cardinality contract: every label here is a bounded enum. Never add a
8//! high-cardinality label (user id, email, ip, jti, …) without an explicit
9//! cardinality review.
10
11use std::sync::Once;
12
13use metrics::{Unit, counter, describe_counter, describe_histogram, histogram};
14
15use crate::error::AuthError;
16
17// -- feature constants --------------------------------------------------------
18
19pub const FEATURE_LOGIN: &str = "login";
20pub const FEATURE_REGISTER: &str = "register";
21pub const FEATURE_REFRESH: &str = "refresh";
22pub const FEATURE_LOGOUT: &str = "logout";
23pub const FEATURE_OAUTH_AUTHORIZE: &str = "oauth_authorize";
24pub const FEATURE_OAUTH_CALLBACK: &str = "oauth_callback";
25pub const FEATURE_EMAIL_CODE_ISSUE: &str = "email_code_issue";
26pub const FEATURE_EMAIL_CODE_VERIFY: &str = "email_code_verify";
27pub const FEATURE_AGREEMENT_ACCEPT: &str = "agreement_accept";
28pub const FEATURE_AGREEMENT_RECORD: &str = "agreement_record";
29pub const FEATURE_FORGOT_PASSWORD: &str = "forgot_password";
30pub const FEATURE_RESET_PASSWORD: &str = "reset_password";
31pub const FEATURE_RESEND_CODE: &str = "resend_code";
32pub const FEATURE_RECLAIM: &str = "reclaim";
33pub const FEATURE_PASSWORD_HASH_HASH: &str = "password_hash_hash";
34pub const FEATURE_PASSWORD_HASH_VERIFY: &str = "password_hash_verify";
35
36pub const OUTCOME_SUCCESS: &str = "success";
37
38// -- lazy one-shot description ----------------------------------------------
39
40static DESCRIBE_ONCE: Once = Once::new();
41
42/// Idempotently register metric descriptions. Called on first metric emit.
43pub fn ensure_described() {
44 DESCRIBE_ONCE.call_once(describe_all);
45}
46
47fn describe_all() {
48 // Usecase layer
49 describe_counter!(
50 "auth_usecase_invocations_total",
51 "Total auth usecase invocations partitioned by feature and outcome"
52 );
53 describe_histogram!(
54 "auth_usecase_duration_seconds",
55 Unit::Seconds,
56 "Wall-clock duration of auth usecases"
57 );
58 describe_counter!(
59 "auth_usecase_errors_total",
60 "Total auth usecase errors partitioned by feature and error kind"
61 );
62
63 // HTTP layer
64 describe_counter!(
65 "auth_http_requests_total",
66 "Total HTTP requests handled by the auth router"
67 );
68 describe_histogram!(
69 "auth_http_request_duration_seconds",
70 Unit::Seconds,
71 "End-to-end HTTP request duration inside the auth router"
72 );
73
74 // PoW
75 describe_counter!(
76 "auth_pow_challenges_issued_total",
77 "Total PoW challenges issued by the auth subsystem"
78 );
79 describe_counter!(
80 "auth_pow_verifications_total",
81 "Total PoW verification attempts partitioned by outcome"
82 );
83 describe_histogram!(
84 "auth_pow_verify_duration_seconds",
85 Unit::Seconds,
86 "Duration of PoW verification attempts"
87 );
88
89 // Password hash
90 describe_histogram!(
91 "auth_password_hash_duration_seconds",
92 Unit::Seconds,
93 "Argon2 hash/verify operation duration"
94 );
95 describe_counter!(
96 "auth_password_policy_rejections_total",
97 "Total passwords rejected by the password policy partitioned by rule"
98 );
99
100 // Email codes
101 describe_counter!(
102 "auth_email_codes_issued_total",
103 "Total email verification codes issued partitioned by channel"
104 );
105 describe_counter!(
106 "auth_email_code_verifications_total",
107 "Total email code verification attempts partitioned by outcome"
108 );
109
110 // OAuth
111 describe_counter!(
112 "auth_oauth_authorize_total",
113 "Total OAuth authorize attempts partitioned by provider and outcome"
114 );
115 describe_counter!(
116 "auth_oauth_callback_total",
117 "Total OAuth callback completions partitioned by provider and outcome"
118 );
119 describe_histogram!(
120 "auth_oauth_provider_fetch_duration_seconds",
121 Unit::Seconds,
122 "Duration of upstream OAuth provider identity fetches"
123 );
124 describe_counter!(
125 "auth_oauth_user_resolution_total",
126 "Total OAuth user-resolution branches taken"
127 );
128
129 // Agreement
130 describe_counter!(
131 "auth_agreement_accepts_total",
132 "Total agreement acceptance events"
133 );
134 describe_counter!(
135 "auth_agreement_records_total",
136 "Total agreement record events"
137 );
138
139 // JWT session lifecycle
140 describe_counter!(
141 "auth_jwt_pair_issued_total",
142 "Total JWT pairs issued partitioned by kind"
143 );
144 describe_counter!(
145 "auth_jwt_session_revoked_total",
146 "Total JWT sessions revoked partitioned by kind"
147 );
148 describe_counter!(
149 "auth_jwt_denied_jti_total",
150 "Total JWT denylist insertions (best-effort)"
151 );
152}
153
154// -- helpers ------------------------------------------------------------------
155
156/// Scoped histogram timer. Records on `Drop` (RAII) and on explicit `observe()`.
157#[must_use = "timer is dropped silently if not bound"]
158pub struct Timer {
159 feature: &'static str,
160 start: std::time::Instant,
161}
162
163impl Timer {
164 pub fn start(feature: &'static str) -> Self {
165 ensure_described();
166 Self {
167 feature,
168 start: std::time::Instant::now(),
169 }
170 }
171
172 pub fn observe(self) {
173 histogram!(
174 "auth_usecase_duration_seconds",
175 "feature" => self.feature
176 )
177 .record(self.start.elapsed().as_secs_f64());
178 }
179}
180
181impl Drop for Timer {
182 fn drop(&mut self) {
183 histogram!(
184 "auth_usecase_duration_seconds",
185 "feature" => self.feature
186 )
187 .record(self.start.elapsed().as_secs_f64());
188 }
189}
190
191/// Record one usecase invocation with its outcome. Safe to call with any
192/// `Result<T, AuthError>` regardless of `T`.
193pub fn record_outcome<T>(feature: &'static str, result: &Result<T, AuthError>) {
194 ensure_described();
195 let outcome = match result {
196 Ok(_) => OUTCOME_SUCCESS,
197 Err(e) => e.error_kind(),
198 };
199 counter!(
200 "auth_usecase_invocations_total",
201 "feature" => feature,
202 "outcome" => outcome
203 )
204 .increment(1);
205 if let Err(e) = result {
206 counter!(
207 "auth_usecase_errors_total",
208 "feature" => feature,
209 "error_kind" => e.error_kind()
210 )
211 .increment(1);
212 }
213}
214
215// -- fine-grained helpers ----------------------------------------------------
216
217pub fn record_pow_challenge_issued() {
218 ensure_described();
219 counter!("auth_pow_challenges_issued_total").increment(1);
220}
221
222#[derive(Copy, Clone)]
223pub enum PowVerifyOutcome {
224 Success,
225 FormatInvalid,
226 Expired,
227 Mismatch,
228}
229
230impl PowVerifyOutcome {
231 fn as_label(self) -> &'static str {
232 match self {
233 Self::Success => "success",
234 Self::FormatInvalid => "format_invalid",
235 Self::Expired => "expired",
236 Self::Mismatch => "mismatch",
237 }
238 }
239}
240
241pub fn record_pow_verify(outcome: PowVerifyOutcome) {
242 ensure_described();
243 counter!(
244 "auth_pow_verifications_total",
245 "outcome" => outcome.as_label()
246 )
247 .increment(1);
248}
249
250#[derive(Copy, Clone)]
251pub enum PasswordOp {
252 Hash,
253 Verify,
254}
255
256impl PasswordOp {
257 fn as_label(self) -> &'static str {
258 match self {
259 Self::Hash => "hash",
260 Self::Verify => "verify",
261 }
262 }
263}
264
265pub struct PasswordOpTimer {
266 op: PasswordOp,
267 start: std::time::Instant,
268}
269
270impl PasswordOpTimer {
271 pub fn start(op: PasswordOp) -> Self {
272 ensure_described();
273 Self {
274 op,
275 start: std::time::Instant::now(),
276 }
277 }
278}
279
280impl Drop for PasswordOpTimer {
281 fn drop(&mut self) {
282 histogram!(
283 "auth_password_hash_duration_seconds",
284 "op" => self.op.as_label()
285 )
286 .record(self.start.elapsed().as_secs_f64());
287 }
288}
289
290pub fn record_password_policy_rejection(rule: &'static str) {
291 ensure_described();
292 counter!("auth_password_policy_rejections_total", "rule" => rule).increment(1);
293}
294
295#[derive(Copy, Clone)]
296pub enum EmailChannel {
297 Verify,
298 Reset,
299 Reclaim,
300}
301
302impl EmailChannel {
303 fn as_label(self) -> &'static str {
304 match self {
305 Self::Verify => "verify",
306 Self::Reset => "reset",
307 Self::Reclaim => "reclaim",
308 }
309 }
310}
311
312pub fn record_email_code_issued(channel: EmailChannel) {
313 ensure_described();
314 counter!(
315 "auth_email_codes_issued_total",
316 "channel" => channel.as_label()
317 )
318 .increment(1);
319}
320
321#[derive(Copy, Clone)]
322pub enum EmailVerifyOutcome {
323 Success,
324 NotFound,
325 Expired,
326 Mismatch,
327}
328
329impl EmailVerifyOutcome {
330 fn as_label(self) -> &'static str {
331 match self {
332 Self::Success => "success",
333 Self::NotFound => "not_found",
334 Self::Expired => "expired",
335 Self::Mismatch => "mismatch",
336 }
337 }
338}
339
340pub fn record_email_code_verify(outcome: EmailVerifyOutcome) {
341 ensure_described();
342 counter!(
343 "auth_email_code_verifications_total",
344 "outcome" => outcome.as_label()
345 )
346 .increment(1);
347}
348
349#[derive(Copy, Clone)]
350pub enum OAuthProvider {
351 Google,
352 Github,
353 Apple,
354}
355
356impl OAuthProvider {
357 pub fn as_label(self) -> &'static str {
358 match self {
359 Self::Google => "google",
360 Self::Github => "github",
361 Self::Apple => "apple",
362 }
363 }
364}
365
366pub struct OAuthProviderFetchTimer {
367 provider: &'static str,
368 start: std::time::Instant,
369}
370
371impl OAuthProviderFetchTimer {
372 pub fn start(provider: OAuthProvider) -> Self {
373 ensure_described();
374 Self {
375 provider: provider.as_label(),
376 start: std::time::Instant::now(),
377 }
378 }
379}
380
381impl Drop for OAuthProviderFetchTimer {
382 fn drop(&mut self) {
383 histogram!(
384 "auth_oauth_provider_fetch_duration_seconds",
385 "provider" => self.provider
386 )
387 .record(self.start.elapsed().as_secs_f64());
388 }
389}
390
391#[derive(Copy, Clone)]
392pub enum OAuthCallbackOutcome {
393 Success,
394 StateExpired,
395 StateMismatch,
396 ProviderError,
397 UserGate,
398 Other,
399}
400
401impl OAuthCallbackOutcome {
402 fn as_label(self) -> &'static str {
403 match self {
404 Self::Success => "success",
405 Self::StateExpired => "state_expired",
406 Self::StateMismatch => "state_mismatch",
407 Self::ProviderError => "provider_error",
408 Self::UserGate => "user_gate",
409 Self::Other => "other",
410 }
411 }
412}
413
414pub fn record_oauth_callback(provider: OAuthProvider, outcome: OAuthCallbackOutcome) {
415 ensure_described();
416 counter!(
417 "auth_oauth_callback_total",
418 "provider" => provider.as_label(),
419 "outcome" => outcome.as_label()
420 )
421 .increment(1);
422}
423
424#[derive(Copy, Clone)]
425pub enum OAuthAuthorizeOutcome {
426 Success,
427 ProviderDisabled,
428 Other,
429}
430
431impl OAuthAuthorizeOutcome {
432 fn as_label(self) -> &'static str {
433 match self {
434 Self::Success => "success",
435 Self::ProviderDisabled => "provider_disabled",
436 Self::Other => "other",
437 }
438 }
439}
440
441pub fn record_oauth_authorize(provider: OAuthProvider, outcome: OAuthAuthorizeOutcome) {
442 ensure_described();
443 counter!(
444 "auth_oauth_authorize_total",
445 "provider" => provider.as_label(),
446 "outcome" => outcome.as_label()
447 )
448 .increment(1);
449}
450
451#[derive(Copy, Clone)]
452pub enum OAuthResolutionBranch {
453 ExistingLink,
454 EmailMatch,
455 AutoRegister,
456}
457
458impl OAuthResolutionBranch {
459 fn as_label(self) -> &'static str {
460 match self {
461 Self::ExistingLink => "existing_link",
462 Self::EmailMatch => "email_match",
463 Self::AutoRegister => "auto_register",
464 }
465 }
466}
467
468pub fn record_oauth_user_resolution(branch: OAuthResolutionBranch) {
469 ensure_described();
470 counter!(
471 "auth_oauth_user_resolution_total",
472 "branch" => branch.as_label()
473 )
474 .increment(1);
475}
476
477pub fn record_agreement_accept(agreement_id: &str) {
478 ensure_described();
479 counter!("auth_agreement_accepts_total", "agreement_id" => agreement_id.to_string())
480 .increment(1);
481}
482
483pub fn record_agreement_record(agreement_id: &str) {
484 ensure_described();
485 counter!("auth_agreement_records_total", "agreement_id" => agreement_id.to_string())
486 .increment(1);
487}
488
489#[derive(Copy, Clone)]
490pub enum JwtPairKind {
491 Login,
492 Refresh,
493}
494
495impl JwtPairKind {
496 fn as_label(self) -> &'static str {
497 match self {
498 Self::Login => "login",
499 Self::Refresh => "refresh",
500 }
501 }
502}
503
504pub fn record_jwt_pair_issued(kind: JwtPairKind) {
505 ensure_described();
506 counter!(
507 "auth_jwt_pair_issued_total",
508 "kind" => kind.as_label()
509 )
510 .increment(1);
511}
512
513#[derive(Copy, Clone)]
514pub enum JwtRevokeKind {
515 Logout,
516 Rotation,
517}
518
519impl JwtRevokeKind {
520 fn as_label(self) -> &'static str {
521 match self {
522 Self::Logout => "logout",
523 Self::Rotation => "rotation",
524 }
525 }
526}
527
528pub fn record_jwt_session_revoked(kind: JwtRevokeKind) {
529 ensure_described();
530 counter!(
531 "auth_jwt_session_revoked_total",
532 "kind" => kind.as_label()
533 )
534 .increment(1);
535}
536
537pub fn record_jwt_denied_jti() {
538 ensure_described();
539 counter!("auth_jwt_denied_jti_total").increment(1);
540}
No newline at end of file