Skip to content
Clone

feat(user): add metrics module and handler middleware

zhenyi committed
parent babe1dc commit ce0da17 Browse files

3 files changed

+696 −0

File tree 3
Added lib/user/handler/middleware/metrics.rs View lib/user/handler/middleware/metrics.rs
@@ -0,0 +1,47 @@
1use axum::extract::{MatchedPath, Request};
2use axum::middleware::Next;
3use axum::response::Response;
4
5/// HTTP-level metrics layer for the `user` crate.
6///
7/// Emits `user_http_requests_total{route,method,status_class}` (counter) and
8/// `user_http_request_duration_seconds{route,method,status_class}` (histogram)
9/// on every request that traverses the router. Uses `MatchedPath` so the
10/// `route` label stays low-cardinality (no user-id path params).
11pub async fn metrics_layer(req: Request, next: Next) -> Response {
12 crate::metrics::ensure_described();
13
14 let route = req
15 .extensions()
16 .get::<MatchedPath>()
17 .map(|m| m.as_str().to_string())
18 .unwrap_or_else(|| req.uri().path().to_string());
19 let method = req.method().as_str().to_string();
20 let started = std::time::Instant::now();
21
22 let resp = next.run(req).await;
23
24 let class = match resp.status().as_u16() / 100 {
25 2 => "2xx",
26 3 => "3xx",
27 4 => "4xx",
28 5 => "5xx",
29 _ => "other",
30 };
31 metrics::counter!(
32 "user_http_requests_total",
33 "route" => route.clone(),
34 "method" => method.clone(),
35 "status_class" => class
36 )
37 .increment(1);
38 metrics::histogram!(
39 "user_http_request_duration_seconds",
40 "route" => route,
41 "method" => method,
42 "status_class" => class
43 )
44 .record(started.elapsed().as_secs_f64());
45
46 resp
47}
Added lib/user/handler/middleware/mod.rs View lib/user/handler/middleware/mod.rs
@@ -0,0 +1 @@
1pub(crate) mod metrics;
Added lib/user/metrics.rs View lib/user/metrics.rs
@@ -0,0 +1,648 @@
1//! Tracing layer for the `user` crate.
2//!
3//! Emits metrics through the `metrics` facade. No exporter is installed here;
4//! the host binary is responsible for binding a recorder. All labels are
5//! bounded to keep cardinality low (no `uid`/`email`/`ip`/`jti`).
6
7use std::time::Instant;
8
9use metrics::{counter, describe_counter, describe_histogram, histogram};
10
11use crate::error::UserError;
12
13// ── Feature constants (one per usecase) ───────────────────────────────────
14
15pub const FEATURE_PROFILE_ME: &str = "profile_me";
16pub const FEATURE_PROFILE_UPDATE: &str = "profile_update";
17pub const FEATURE_PROFILE_UPDATE_ADMIN: &str = "profile_update_admin";
18pub const FEATURE_PROFILE_VIEW: &str = "profile_view";
19pub const FEATURE_PROFILE_PUBLIC_VIEW: &str = "profile_public_view";
20pub const FEATURE_PROFILE_MEMES_LIST: &str = "profile_memes_list";
21pub const FEATURE_PROFILE_MEMES_CREATE: &str = "profile_memes_create";
22pub const FEATURE_PROFILE_MEMES_DELETE: &str = "profile_memes_delete";
23
24pub const FEATURE_AVATAR_UPLOAD: &str = "avatar_upload";
25pub const FEATURE_AVATAR_DELETE: &str = "avatar_delete";
26pub const FEATURE_AVATAR_CURRENT: &str = "avatar_current";
27
28pub const FEATURE_SOCIAL_FOLLOW: &str = "social_follow";
29pub const FEATURE_SOCIAL_UNFOLLOW: &str = "social_unfollow";
30pub const FEATURE_SOCIAL_LIST_FOLLOWERS: &str = "social_list_followers";
31pub const FEATURE_SOCIAL_LIST_FOLLOWING: &str = "social_list_following";
32pub const FEATURE_SOCIAL_BLOCK: &str = "social_block";
33pub const FEATURE_SOCIAL_UNBLOCK: &str = "social_unblock";
34pub const FEATURE_SOCIAL_LIST_BLOCKS: &str = "social_list_blocks";
35
36pub const FEATURE_DEVICES_LIST: &str = "devices_list";
37pub const FEATURE_DEVICES_UPDATE: &str = "devices_update";
38pub const FEATURE_DEVICES_DELETE: &str = "devices_delete";
39
40pub const FEATURE_SESSIONS_LIST: &str = "sessions_list";
41pub const FEATURE_SESSIONS_REVOKE_ONE: &str = "sessions_revoke_one";
42pub const FEATURE_SESSIONS_REVOKE_ALL: &str = "sessions_revoke_all";
43
44pub const FEATURE_EMAILS_LIST: &str = "emails_list";
45pub const FEATURE_EMAILS_ADD: &str = "emails_add";
46pub const FEATURE_EMAILS_SET_PRIMARY: &str = "emails_set_primary";
47pub const FEATURE_EMAILS_DELETE: &str = "emails_delete";
48pub const FEATURE_EMAILS_REQUEST_CHANGE: &str = "emails_request_change";
49pub const FEATURE_EMAILS_CONFIRM_CHANGE: &str = "emails_confirm_change";
50pub const FEATURE_EMAILS_CANCEL_CHANGE: &str = "emails_cancel_change";
51
52pub const FEATURE_RECOVERY_CODES_LIST: &str = "recovery_codes_list";
53pub const FEATURE_RECOVERY_CODES_REGENERATE: &str = "recovery_codes_regenerate";
54pub const FEATURE_ACCESS_KEYS_LIST: &str = "access_keys_list";
55pub const FEATURE_ACCESS_KEYS_CREATE: &str = "access_keys_create";
56pub const FEATURE_ACCESS_KEYS_DELETE: &str = "access_keys_delete";
57pub const FEATURE_SSH_KEYS_LIST: &str = "ssh_keys_list";
58pub const FEATURE_SSH_KEYS_ADD: &str = "ssh_keys_add";
59pub const FEATURE_SSH_KEYS_DELETE: &str = "ssh_keys_delete";
60pub const FEATURE_PUSH_SUBS_LIST: &str = "push_subs_list";
61pub const FEATURE_PUSH_SUBS_UPSERT: &str = "push_subs_upsert";
62pub const FEATURE_PUSH_SUBS_DELETE: &str = "push_subs_delete";
63
64pub const FEATURE_NOTIFICATIONS_LIST: &str = "notifications_list";
65pub const FEATURE_NOTIFICATIONS_MARK_READ: &str = "notifications_mark_read";
66pub const FEATURE_NOTIFICATIONS_MARK_ALL_READ: &str = "notifications_mark_all_read";
67pub const FEATURE_NOTIFICATIONS_UNREAD_COUNT: &str = "notifications_unread_count";
68pub const FEATURE_NOTIFICATIONS_RECENT_MENTIONS: &str = "notifications_recent_mentions";
69pub const FEATURE_REPORTS_SUBMIT: &str = "reports_submit";
70pub const FEATURE_REPORTS_LIST_MINE: &str = "reports_list_mine";
71pub const FEATURE_SAVED_MESSAGES_LIST: &str = "saved_messages_list";
72pub const FEATURE_SAVED_MESSAGES_SAVE: &str = "saved_messages_save";
73pub const FEATURE_SAVED_MESSAGES_UNSAVE: &str = "saved_messages_unsave";
74pub const FEATURE_SCHEDULED_MESSAGES_LIST: &str = "scheduled_messages_list";
75pub const FEATURE_SCHEDULED_MESSAGES_CANCEL: &str = "scheduled_messages_cancel";
76
77pub const FEATURE_SUSPENSIONS_CREATE: &str = "suspensions_create";
78pub const FEATURE_SUSPENSIONS_REVOKE: &str = "suspensions_revoke";
79pub const FEATURE_SUSPENSIONS_LIST_SELF: &str = "suspensions_list_self";
80pub const FEATURE_SUSPENSIONS_LIST_USER: &str = "suspensions_list_user";
81pub const FEATURE_SUSPENSIONS_LIST_ALL: &str = "suspensions_list_all";
82
83pub const FEATURE_DELETION_REQUEST: &str = "deletion_request";
84pub const FEATURE_DELETION_CANCEL: &str = "deletion_cancel";
85pub const FEATURE_DELETION_CONFIRM: &str = "deletion_confirm";
86pub const FEATURE_DELETION_LIST_PENDING: &str = "deletion_list_pending";
87pub const FEATURE_DELETION_COMPLETE: &str = "deletion_complete";
88
89pub const FEATURE_PREFERENCES_GET: &str = "preferences_get";
90pub const FEATURE_PREFERENCES_UPDATE: &str = "preferences_update";
91
92// ── Outcome labels (bounded enums) ────────────────────────────────────────
93
94pub const OUTCOME_SUCCESS: &str = "success";
95pub const OUTCOME_ERROR: &str = "error";
96
97// ── Metric description registration (Once) ────────────────────────────────
98
99static DESCRIBE_ONCE: std::sync::Once = std::sync::Once::new();
100
101pub fn ensure_described() {
102 DESCRIBE_ONCE.call_once(describe_all);
103}
104
105fn describe_all() {
106 describe_counter!(
107 "user_http_requests_total",
108 "Total HTTP requests handled by the user service"
109 );
110 describe_histogram!(
111 "user_http_request_duration_seconds",
112 metrics::Unit::Seconds,
113 "User HTTP request latency"
114 );
115 describe_counter!(
116 "user_usecase_invocations_total",
117 "Total usecase invocations labeled by feature and outcome"
118 );
119 describe_counter!(
120 "user_usecase_errors_total",
121 "Total usecase errors labeled by feature and error_kind"
122 );
123 describe_histogram!(
124 "user_usecase_duration_seconds",
125 metrics::Unit::Seconds,
126 "Usecase execution latency"
127 );
128 describe_counter!(
129 "user_storage_operations_total",
130 "Object storage operations initiated by the user service"
131 );
132}
133
134// ── Timer (RAII) ──────────────────────────────────────────────────────────
135
136/// Records the usecase execution duration on drop.
137pub struct Timer {
138 pub feature: &'static str,
139 pub start: Instant,
140}
141
142impl Timer {
143 pub fn start(feature: &'static str) -> Self {
144 ensure_described();
145 Self {
146 feature,
147 start: Instant::now(),
148 }
149 }
150
151 pub fn observe(self) {
152 histogram!(
153 "user_usecase_duration_seconds",
154 "feature" => self.feature.to_string()
155 )
156 .record(self.start.elapsed().as_secs_f64());
157 }
158}
159
160impl Drop for Timer {
161 fn drop(&mut self) {
162 // Avoid double-counting: only record if not yet consumed via `observe`.
163 let elapsed = self.start.elapsed().as_secs_f64();
164 histogram!(
165 "user_usecase_duration_seconds",
166 "feature" => self.feature.to_string()
167 )
168 .record(elapsed);
169 }
170}
171
172// ── Generic outcome counter ───────────────────────────────────────────────
173
174/// Bump invocation counter (always) and, on `Err`, an error counter labeled
175/// by `UserError::error_kind()`. Drop semantics of `_timer` capture latency.
176pub fn record_outcome<T>(feature: &'static str, result: &Result<T, UserError>) {
177 match result {
178 Ok(_) => {
179 counter!(
180 "user_usecase_invocations_total",
181 "feature" => feature.to_string(),
182 "outcome" => OUTCOME_SUCCESS
183 )
184 .increment(1);
185 }
186 Err(e) => {
187 let kind = e.error_kind();
188 counter!(
189 "user_usecase_invocations_total",
190 "feature" => feature.to_string(),
191 "outcome" => kind
192 )
193 .increment(1);
194 counter!(
195 "user_usecase_errors_total",
196 "feature" => feature.to_string(),
197 "outcome" => kind
198 )
199 .increment(1);
200 }
201 }
202}
203
204// ── Avatar helpers ────────────────────────────────────────────────────────
205
206#[derive(Debug, Clone, Copy)]
207pub enum AvatarFormat {
208 Jpg,
209 Png,
210 WebP,
211 Unknown,
212}
213
214impl AvatarFormat {
215 pub fn as_label(self) -> &'static str {
216 match self {
217 AvatarFormat::Jpg => "jpg",
218 AvatarFormat::Png => "png",
219 AvatarFormat::WebP => "webp",
220 AvatarFormat::Unknown => "unknown",
221 }
222 }
223}
224
225#[derive(Debug, Clone, Copy)]
226pub enum AvatarOp {
227 Upload,
228 Delete,
229}
230
231impl AvatarOp {
232 pub fn as_label(self) -> &'static str {
233 match self {
234 AvatarOp::Upload => "upload",
235 AvatarOp::Delete => "delete",
236 }
237 }
238}
239
240pub fn record_avatar_operation(op: AvatarOp, outcome: &'static str) {
241 counter!(
242 "user_usecase_invocations_total",
243 "feature" => format!("avatar_{}", op.as_label()),
244 "outcome" => outcome
245 )
246 .increment(1);
247}
248
249// ── Social helpers ────────────────────────────────────────────────────────
250
251#[derive(Debug, Clone, Copy)]
252pub enum SocialFollowOutcome {
253 SelfFollow,
254 AlreadyFollowing,
255 NotFound,
256 Created,
257}
258
259impl SocialFollowOutcome {
260 pub fn as_label(self) -> &'static str {
261 match self {
262 SocialFollowOutcome::SelfFollow => "self_follow",
263 SocialFollowOutcome::AlreadyFollowing => "already_following",
264 SocialFollowOutcome::NotFound => "not_found",
265 SocialFollowOutcome::Created => "created",
266 }
267 }
268}
269
270pub fn record_social_follow(outcome: SocialFollowOutcome) {
271 counter!(
272 "user_usecase_invocations_total",
273 "feature" => FEATURE_SOCIAL_FOLLOW,
274 "outcome" => outcome.as_label()
275 )
276 .increment(1);
277}
278
279#[derive(Debug, Clone, Copy)]
280pub enum SocialBlockOutcome {
281 SelfBlock,
282 NotFound,
283 Created,
284}
285
286impl SocialBlockOutcome {
287 pub fn as_label(self) -> &'static str {
288 match self {
289 SocialBlockOutcome::SelfBlock => "self_block",
290 SocialBlockOutcome::NotFound => "not_found",
291 SocialBlockOutcome::Created => "created",
292 }
293 }
294}
295
296pub fn record_social_block(outcome: SocialBlockOutcome) {
297 counter!(
298 "user_usecase_invocations_total",
299 "feature" => FEATURE_SOCIAL_BLOCK,
300 "outcome" => outcome.as_label()
301 )
302 .increment(1);
303}
304
305// ── Email helpers ─────────────────────────────────────────────────────────
306
307#[derive(Debug, Clone, Copy)]
308pub enum EmailOp {
309 Add,
310 SetPrimary,
311 Delete,
312 RequestChange,
313 ConfirmChange,
314 CancelChange,
315}
316
317impl EmailOp {
318 pub fn as_label(self) -> &'static str {
319 match self {
320 EmailOp::Add => "add",
321 EmailOp::SetPrimary => "set_primary",
322 EmailOp::Delete => "delete",
323 EmailOp::RequestChange => "request_change",
324 EmailOp::ConfirmChange => "confirm_change",
325 EmailOp::CancelChange => "cancel_change",
326 }
327 }
328}
329
330#[derive(Debug, Clone, Copy)]
331pub enum EmailChangeOutcome {
332 Success,
333 NotFound,
334 Expired,
335 Mismatch,
336 Conflict,
337 TicketNotFound,
338 AlreadyPrimary,
339 Forbidden,
340 Other,
341}
342
343impl EmailChangeOutcome {
344 pub fn as_label(self) -> &'static str {
345 match self {
346 EmailChangeOutcome::Success => "success",
347 EmailChangeOutcome::NotFound => "not_found",
348 EmailChangeOutcome::Expired => "expired",
349 EmailChangeOutcome::Mismatch => "mismatch",
350 EmailChangeOutcome::Conflict => "conflict",
351 EmailChangeOutcome::TicketNotFound => "ticket_not_found",
352 EmailChangeOutcome::AlreadyPrimary => "already_primary",
353 EmailChangeOutcome::Forbidden => "forbidden",
354 EmailChangeOutcome::Other => "other",
355 }
356 }
357}
358
359pub fn record_email_operation(op: EmailOp, outcome: EmailChangeOutcome) {
360 counter!(
361 "user_usecase_invocations_total",
362 "feature" => format!("emails_{}", op.as_label()),
363 "outcome" => outcome.as_label()
364 )
365 .increment(1);
366}
367
368// ── Security helpers ──────────────────────────────────────────────────────
369
370#[derive(Debug, Clone, Copy)]
371pub enum SecurityResourceOp {
372 List,
373 Create,
374 Delete,
375 Regenerate,
376 Upsert,
377 Add,
378}
379
380impl SecurityResourceOp {
381 pub fn as_label(self) -> &'static str {
382 match self {
383 SecurityResourceOp::List => "list",
384 SecurityResourceOp::Create => "create",
385 SecurityResourceOp::Delete => "delete",
386 SecurityResourceOp::Regenerate => "regenerate",
387 SecurityResourceOp::Upsert => "upsert",
388 SecurityResourceOp::Add => "add",
389 }
390 }
391}
392
393#[derive(Debug, Clone, Copy)]
394pub enum SecurityResource {
395 RecoveryCode,
396 AccessKey,
397 SshKey,
398 PushSubscription,
399}
400
401impl SecurityResource {
402 pub fn as_label(self) -> &'static str {
403 match self {
404 SecurityResource::RecoveryCode => "recovery_code",
405 SecurityResource::AccessKey => "access_key",
406 SecurityResource::SshKey => "ssh_key",
407 SecurityResource::PushSubscription => "push_subscription",
408 }
409 }
410}
411
412pub fn record_security_operation(resource: SecurityResource, op: SecurityResourceOp, outcome: &'static str) {
413 counter!(
414 "user_usecase_invocations_total",
415 "feature" => format!("{}_{}", resource.as_label(), op.as_label()),
416 "outcome" => outcome
417 )
418 .increment(1);
419}
420
421// ── Notification helpers ──────────────────────────────────────────────────
422
423#[derive(Debug, Clone, Copy)]
424pub enum NotificationOp {
425 List,
426 MarkRead,
427 MarkAllRead,
428 UnreadCount,
429 RecentMentions,
430 SaveMessage,
431 UnsaveMessage,
432 ListSaved,
433 ListScheduled,
434 CancelScheduled,
435}
436
437impl NotificationOp {
438 pub fn as_label(self) -> &'static str {
439 match self {
440 NotificationOp::List => "list",
441 NotificationOp::MarkRead => "mark_read",
442 NotificationOp::MarkAllRead => "mark_all_read",
443 NotificationOp::UnreadCount => "unread_count",
444 NotificationOp::RecentMentions => "recent_mentions",
445 NotificationOp::SaveMessage => "save",
446 NotificationOp::UnsaveMessage => "unsave",
447 NotificationOp::ListSaved => "list_saved",
448 NotificationOp::ListScheduled => "list_scheduled",
449 NotificationOp::CancelScheduled => "cancel_scheduled",
450 }
451 }
452}
453
454#[derive(Debug, Clone, Copy)]
455pub enum NotificationKind {
456 Notification,
457 SavedMessage,
458 ScheduledMessage,
459 Report,
460}
461
462impl NotificationKind {
463 pub fn as_label(self) -> &'static str {
464 match self {
465 NotificationKind::Notification => "notification",
466 NotificationKind::SavedMessage => "saved_message",
467 NotificationKind::ScheduledMessage => "scheduled_message",
468 NotificationKind::Report => "report",
469 }
470 }
471}
472
473pub fn record_notification_operation(kind: NotificationKind, op: NotificationOp, outcome: &'static str) {
474 counter!(
475 "user_usecase_invocations_total",
476 "feature" => format!("{}_{}", kind.as_label(), op.as_label()),
477 "outcome" => outcome
478 )
479 .increment(1);
480}
481
482#[derive(Debug, Clone, Copy)]
483pub enum ReportOp {
484 Submit,
485 ListMine,
486}
487
488impl ReportOp {
489 pub fn as_label(self) -> &'static str {
490 match self {
491 ReportOp::Submit => "submit",
492 ReportOp::ListMine => "list_mine",
493 }
494 }
495}
496
497pub fn record_report_operation(op: ReportOp, outcome: &'static str) {
498 counter!(
499 "user_usecase_invocations_total",
500 "feature" => format!("reports_{}", op.as_label()),
501 "outcome" => outcome
502 )
503 .increment(1);
504}
505
506// ── Suspension helpers ────────────────────────────────────────────────────
507
508#[derive(Debug, Clone, Copy)]
509pub enum SuspensionOp {
510 Create,
511 Revoke,
512 ListSelf,
513 ListUser,
514 ListAll,
515}
516
517impl SuspensionOp {
518 pub fn as_label(self) -> &'static str {
519 match self {
520 SuspensionOp::Create => "create",
521 SuspensionOp::Revoke => "revoke",
522 SuspensionOp::ListSelf => "list_self",
523 SuspensionOp::ListUser => "list_user",
524 SuspensionOp::ListAll => "list_all",
525 }
526 }
527}
528
529#[derive(Debug, Clone, Copy)]
530pub enum SuspensionOutcome {
531 Success,
532 NotFound,
533 AlreadyActive,
534 BadRequest,
535 Other,
536}
537
538impl SuspensionOutcome {
539 pub fn as_label(self) -> &'static str {
540 match self {
541 SuspensionOutcome::Success => "success",
542 SuspensionOutcome::NotFound => "not_found",
543 SuspensionOutcome::AlreadyActive => "already_active",
544 SuspensionOutcome::BadRequest => "bad_request",
545 SuspensionOutcome::Other => "other",
546 }
547 }
548}
549
550pub fn record_suspension_operation(op: SuspensionOp, outcome: SuspensionOutcome) {
551 counter!(
552 "user_usecase_invocations_total",
553 "feature" => format!("suspensions_{}", op.as_label()),
554 "outcome" => outcome.as_label()
555 )
556 .increment(1);
557}
558
559// ── Deletion helpers ──────────────────────────────────────────────────────
560
561#[derive(Debug, Clone, Copy)]
562pub enum DeletionOp {
563 Request,
564 Cancel,
565 Confirm,
566 ListPending,
567 Complete,
568}
569
570impl DeletionOp {
571 pub fn as_label(self) -> &'static str {
572 match self {
573 DeletionOp::Request => "request",
574 DeletionOp::Cancel => "cancel",
575 DeletionOp::Confirm => "confirm",
576 DeletionOp::ListPending => "list_pending",
577 DeletionOp::Complete => "complete",
578 }
579 }
580}
581
582#[derive(Debug, Clone, Copy)]
583pub enum DeletionStatus {
584 Pending,
585 Scheduled,
586 Cancelled,
587 Completed,
588 Other,
589}
590
591impl DeletionStatus {
592 pub fn as_label(self) -> &'static str {
593 match self {
594 DeletionStatus::Pending => "pending",
595 DeletionStatus::Scheduled => "scheduled",
596 DeletionStatus::Cancelled => "cancelled",
597 DeletionStatus::Completed => "completed",
598 DeletionStatus::Other => "other",
599 }
600 }
601}
602
603pub fn record_deletion_operation(op: DeletionOp, status: DeletionStatus) {
604 counter!(
605 "user_usecase_invocations_total",
606 "feature" => format!("deletion_{}", op.as_label()),
607 "outcome" => status.as_label()
608 )
609 .increment(1);
610}
611
612// ── Preferences helpers ───────────────────────────────────────────────────
613
614#[derive(Debug, Clone, Copy)]
615pub enum PreferencesOp {
616 Get,
617 Update,
618}
619
620impl PreferencesOp {
621 pub fn as_label(self) -> &'static str {
622 match self {
623 PreferencesOp::Get => "get",
624 PreferencesOp::Update => "update",
625 }
626 }
627}
628
629pub fn record_preferences_operation(op: PreferencesOp, outcome: &'static str) {
630 counter!(
631 "user_usecase_invocations_total",
632 "feature" => format!("preferences_{}", op.as_label()),
633 "outcome" => outcome
634 )
635 .increment(1);
636}
637
638// ── Storage sub-counter ───────────────────────────────────────────────────
639
640pub fn record_storage_operation(op: &'static str, kind: &'static str, outcome: &'static str) {
641 counter!(
642 "user_storage_operations_total",
643 "op" => op,
644 "kind" => kind,
645 "outcome" => outcome
646 )
647 .increment(1);
648}