Skip to content
Clone

feat(space): add metrics module and handler middleware

zhenyi committed
parent 1f1aa45 commit babe1dc Browse files

3 files changed

+255 −0

File tree 3
Added lib/space/handler/middleware/metrics.rs View lib/space/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 `space` crate.
6///
7/// Emits `space_http_requests_total{route,method,status_class}` (counter) and
8/// `space_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 space-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 "space_http_requests_total",
33 "route" => route.clone(),
34 "method" => method.clone(),
35 "status_class" => class
36 )
37 .increment(1);
38 metrics::histogram!(
39 "space_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/space/handler/middleware/mod.rs View lib/space/handler/middleware/mod.rs
@@ -0,0 +1 @@
1pub(crate) mod metrics;
Added lib/space/metrics.rs View lib/space/metrics.rs
@@ -0,0 +1,207 @@
1//! Tracing layer for the `space` 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 `space_id`/`user_id`/`role_id`).
6
7use std::time::Instant;
8
9use metrics::{counter, describe_counter, describe_histogram, histogram};
10
11use crate::error::SpaceError;
12
13// ── Feature constants (one per usecase) ───────────────────────────────────
14
15// spaces
16pub const FEATURE_SPACES_CREATE: &str = "spaces_create";
17pub const FEATURE_SPACES_GET: &str = "spaces_get";
18pub const FEATURE_SPACES_UPDATE: &str = "spaces_update";
19pub const FEATURE_SPACES_DELETE: &str = "spaces_delete";
20pub const FEATURE_SPACES_LIST: &str = "spaces_list";
21
22// bans
23pub const FEATURE_BANS_CREATE: &str = "bans_create";
24pub const FEATURE_BANS_GET: &str = "bans_get";
25pub const FEATURE_BANS_UPDATE: &str = "bans_update";
26pub const FEATURE_BANS_REMOVE: &str = "bans_remove";
27pub const FEATURE_BANS_LIST: &str = "bans_list";
28
29// invites
30pub const FEATURE_INVITES_CREATE: &str = "invites_create";
31pub const FEATURE_INVITES_REVOKE: &str = "invites_revoke";
32pub const FEATURE_INVITES_ACCEPT: &str = "invites_accept";
33pub const FEATURE_INVITES_LIST: &str = "invites_list";
34
35// members
36pub const FEATURE_MEMBERS_ADD: &str = "members_add";
37pub const FEATURE_MEMBERS_GET: &str = "members_get";
38pub const FEATURE_MEMBERS_UPDATE: &str = "members_update";
39pub const FEATURE_MEMBERS_REMOVE: &str = "members_remove";
40pub const FEATURE_MEMBERS_LIST: &str = "members_list";
41
42// roles
43pub const FEATURE_ROLES_CREATE: &str = "roles_create";
44pub const FEATURE_ROLES_GET: &str = "roles_get";
45pub const FEATURE_ROLES_UPDATE: &str = "roles_update";
46pub const FEATURE_ROLES_DELETE: &str = "roles_delete";
47pub const FEATURE_ROLES_LIST: &str = "roles_list";
48pub const FEATURE_ROLES_ASSIGN: &str = "roles_assign";
49pub const FEATURE_ROLES_UNASSIGN: &str = "roles_unassign";
50
51// channels
52pub const FEATURE_CHANNELS_GET: &str = "channels_get";
53pub const FEATURE_CHANNELS_LIST: &str = "channels_list";
54pub const FEATURE_CHANNELS_ENSURE: &str = "channels_ensure";
55
56// channel_members
57pub const FEATURE_CHANNEL_MEMBERS_ADD: &str = "channel_members_add";
58pub const FEATURE_CHANNEL_MEMBERS_GET: &str = "channel_members_get";
59pub const FEATURE_CHANNEL_MEMBERS_UPDATE: &str = "channel_members_update";
60pub const FEATURE_CHANNEL_MEMBERS_REMOVE: &str = "channel_members_remove";
61pub const FEATURE_CHANNEL_MEMBERS_LIST: &str = "channel_members_list";
62
63// overwrites
64pub const FEATURE_OVERWRITES_SET_ROLE: &str = "overwrites_set_role";
65pub const FEATURE_OVERWRITES_REMOVE_ROLE: &str = "overwrites_remove_role";
66pub const FEATURE_OVERWRITES_LIST_ROLE: &str = "overwrites_list_role";
67pub const FEATURE_OVERWRITES_SET_USER: &str = "overwrites_set_user";
68pub const FEATURE_OVERWRITES_REMOVE_USER: &str = "overwrites_remove_user";
69pub const FEATURE_OVERWRITES_LIST_USER: &str = "overwrites_list_user";
70
71// ── Outcome labels (bounded enums) ────────────────────────────────────────
72
73pub const OUTCOME_SUCCESS: &str = "success";
74
75// ── Metric description registration (Once) ────────────────────────────────
76
77static DESCRIBE_ONCE: std::sync::Once = std::sync::Once::new();
78
79pub fn ensure_described() {
80 DESCRIBE_ONCE.call_once(describe_all);
81}
82
83fn describe_all() {
84 describe_counter!(
85 "space_http_requests_total",
86 "Total HTTP requests handled by the space service"
87 );
88 describe_histogram!(
89 "space_http_request_duration_seconds",
90 metrics::Unit::Seconds,
91 "Space HTTP request latency"
92 );
93 describe_counter!(
94 "space_usecase_invocations_total",
95 "Total usecase invocations labeled by feature and outcome"
96 );
97 describe_counter!(
98 "space_usecase_errors_total",
99 "Total usecase errors labeled by feature and error_kind"
100 );
101 describe_histogram!(
102 "space_usecase_duration_seconds",
103 metrics::Unit::Seconds,
104 "Usecase execution latency"
105 );
106 describe_counter!(
107 "space_storage_operations_total",
108 "Object storage operations initiated by the space service"
109 );
110}
111
112// ── Timer (RAII) ──────────────────────────────────────────────────────────
113
114/// Records the usecase execution duration on drop.
115pub struct Timer {
116 pub feature: &'static str,
117 pub start: Instant,
118}
119
120impl Timer {
121 pub fn start(feature: &'static str) -> Self {
122 ensure_described();
123 Self {
124 feature,
125 start: Instant::now(),
126 }
127 }
128}
129
130impl Drop for Timer {
131 fn drop(&mut self) {
132 let elapsed = self.start.elapsed().as_secs_f64();
133 histogram!(
134 "space_usecase_duration_seconds",
135 "feature" => self.feature.to_string()
136 )
137 .record(elapsed);
138 }
139}
140
141// ── Generic outcome counter ───────────────────────────────────────────────
142
143/// Bump invocation counter (always) and, on `Err`, an error counter labeled
144/// by `SpaceError::error_kind()`. Drop semantics of `_timer` capture latency.
145pub fn record_outcome<T>(feature: &'static str, result: &Result<T, SpaceError>) {
146 match result {
147 Ok(_) => {
148 counter!(
149 "space_usecase_invocations_total",
150 "feature" => feature.to_string(),
151 "outcome" => OUTCOME_SUCCESS
152 )
153 .increment(1);
154 }
155 Err(e) => {
156 let kind = e.error_kind();
157 counter!(
158 "space_usecase_invocations_total",
159 "feature" => feature.to_string(),
160 "outcome" => kind
161 )
162 .increment(1);
163 counter!(
164 "space_usecase_errors_total",
165 "feature" => feature.to_string(),
166 "outcome" => kind
167 )
168 .increment(1);
169 }
170 }
171}
172
173// ── Invite accept helper ──────────────────────────────────────────────────
174
175#[derive(Debug, Clone, Copy)]
176pub enum InviteAcceptOutcome {
177 Success,
178 NotFound,
179 Forbidden,
180 AlreadyAccepted,
181 Revoked,
182 Expired,
183 Other,
184}
185
186impl InviteAcceptOutcome {
187 pub fn as_label(self) -> &'static str {
188 match self {
189 InviteAcceptOutcome::Success => "success",
190 InviteAcceptOutcome::NotFound => "not_found",
191 InviteAcceptOutcome::Forbidden => "forbidden",
192 InviteAcceptOutcome::AlreadyAccepted => "already_accepted",
193 InviteAcceptOutcome::Revoked => "revoked",
194 InviteAcceptOutcome::Expired => "expired",
195 InviteAcceptOutcome::Other => "other",
196 }
197 }
198}
199
200pub fn record_invite_accept(outcome: InviteAcceptOutcome) {
201 counter!(
202 "space_usecase_invocations_total",
203 "feature" => FEATURE_INVITES_ACCEPT,
204 "outcome" => outcome.as_label()
205 )
206 .increment(1);
207}