feat(project): add metrics module and handler middleware
zhenyi committed
Added lib/project/handler/middleware/metrics.rs lib/project/handler/middleware/metrics.rs +47 −0 View lib/project/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 `project` crate.
6///
7/// Emits `project_http_requests_total{route,method,status_class}` (counter) and
8/// `project_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 "project_http_requests_total",
33 "route" => route.clone(),
34 "method" => method.clone(),
35 "status_class" => class
36 )
37 .increment(1);
38 metrics::histogram!(
39 "project_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/project/handler/middleware/mod.rs lib/project/handler/middleware/mod.rs +1 −0 View lib/project/handler/middleware/mod.rs
@@ -0,0 +1 @@
1pub(crate) mod metrics;
Added lib/project/metrics.rs lib/project/metrics.rs +194 −0 View lib/project/metrics.rs
@@ -0,0 +1,194 @@
1//! Tracing layer for the `project` 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`/`project_id`/`user_id`).
6
7use std::time::Instant;
8
9use metrics::{counter, describe_counter, describe_histogram, histogram};
10
11use crate::error::ProjectError;
12
13// ── Feature constants (one per usecase) ───────────────────────────────────
14
15// lifecycle
16pub const FEATURE_PROJECTS_CREATE: &str = "projects_create";
17pub const FEATURE_PROJECTS_GET: &str = "projects_get";
18pub const FEATURE_PROJECTS_UPDATE: &str = "projects_update";
19pub const FEATURE_PROJECTS_DELETE: &str = "projects_delete";
20pub const FEATURE_PROJECTS_LIST: &str = "projects_list";
21
22// members
23pub const FEATURE_PROJECT_MEMBERS_ADD: &str = "project_members_add";
24pub const FEATURE_PROJECT_MEMBERS_GET: &str = "project_members_get";
25pub const FEATURE_PROJECT_MEMBERS_UPDATE: &str = "project_members_update";
26pub const FEATURE_PROJECT_MEMBERS_REMOVE: &str = "project_members_remove";
27pub const FEATURE_PROJECT_MEMBERS_LIST: &str = "project_members_list";
28
29// views
30pub const FEATURE_PROJECT_VIEWS_CREATE: &str = "project_views_create";
31pub const FEATURE_PROJECT_VIEWS_GET: &str = "project_views_get";
32pub const FEATURE_PROJECT_VIEWS_UPDATE: &str = "project_views_update";
33pub const FEATURE_PROJECT_VIEWS_DELETE: &str = "project_views_delete";
34pub const FEATURE_PROJECT_VIEWS_LIST: &str = "project_views_list";
35
36// documents
37pub const FEATURE_PROJECT_DOCS_CREATE: &str = "project_docs_create";
38pub const FEATURE_PROJECT_DOCS_GET: &str = "project_docs_get";
39pub const FEATURE_PROJECT_DOCS_UPDATE: &str = "project_docs_update";
40pub const FEATURE_PROJECT_DOCS_DELETE: &str = "project_docs_delete";
41pub const FEATURE_PROJECT_DOCS_LIST: &str = "project_docs_list";
42
43// links
44pub const FEATURE_PROJECT_LINKS_ADD: &str = "project_links_add";
45pub const FEATURE_PROJECT_LINKS_REMOVE: &str = "project_links_remove";
46pub const FEATURE_PROJECT_LINKS_LIST: &str = "project_links_list";
47
48// favorites
49pub const FEATURE_PROJECT_FAVORITES_ADD: &str = "project_favorites_add";
50pub const FEATURE_PROJECT_FAVORITES_REMOVE: &str = "project_favorites_remove";
51pub const FEATURE_PROJECT_FAVORITES_LIST: &str = "project_favorites_list";
52
53// activities
54pub const FEATURE_PROJECT_ACTIVITIES_LIST: &str = "project_activities_list";
55
56// ── Outcome labels (bounded enums) ────────────────────────────────────────
57
58pub const OUTCOME_SUCCESS: &str = "success";
59
60// ── Metric description registration (Once) ────────────────────────────────
61
62static DESCRIBE_ONCE: std::sync::Once = std::sync::Once::new();
63
64pub fn ensure_described() {
65 DESCRIBE_ONCE.call_once(describe_all);
66}
67
68fn describe_all() {
69 describe_counter!(
70 "project_http_requests_total",
71 "Total HTTP requests handled by the project service"
72 );
73 describe_histogram!(
74 "project_http_request_duration_seconds",
75 metrics::Unit::Seconds,
76 "Project HTTP request latency"
77 );
78 describe_counter!(
79 "project_usecase_invocations_total",
80 "Total usecase invocations labeled by feature and outcome"
81 );
82 describe_counter!(
83 "project_usecase_errors_total",
84 "Total usecase errors labeled by feature and error_kind"
85 );
86 describe_histogram!(
87 "project_usecase_duration_seconds",
88 metrics::Unit::Seconds,
89 "Usecase execution latency"
90 );
91 describe_counter!(
92 "project_storage_operations_total",
93 "Object storage operations initiated by the project service"
94 );
95}
96
97// ── Timer (RAII) ──────────────────────────────────────────────────────────
98
99/// Records the usecase execution duration on drop.
100pub struct Timer {
101 pub feature: &'static str,
102 pub start: Instant,
103}
104
105impl Timer {
106 pub fn start(feature: &'static str) -> Self {
107 ensure_described();
108 Self {
109 feature,
110 start: Instant::now(),
111 }
112 }
113}
114
115impl Drop for Timer {
116 fn drop(&mut self) {
117 let elapsed = self.start.elapsed().as_secs_f64();
118 histogram!(
119 "project_usecase_duration_seconds",
120 "feature" => self.feature.to_string()
121 )
122 .record(elapsed);
123 }
124}
125
126// ── Generic outcome counter ───────────────────────────────────────────────
127
128/// Bump invocation counter (always) and, on `Err`, an error counter labeled
129/// by `ProjectError::error_kind()`. Drop semantics of `_timer` capture latency.
130pub fn record_outcome<T>(feature: &'static str, result: &Result<T, ProjectError>) {
131 match result {
132 Ok(_) => {
133 counter!(
134 "project_usecase_invocations_total",
135 "feature" => feature.to_string(),
136 "outcome" => OUTCOME_SUCCESS
137 )
138 .increment(1);
139 }
140 Err(e) => {
141 let kind = e.error_kind();
142 counter!(
143 "project_usecase_invocations_total",
144 "feature" => feature.to_string(),
145 "outcome" => kind
146 )
147 .increment(1);
148 counter!(
149 "project_usecase_errors_total",
150 "feature" => feature.to_string(),
151 "outcome" => kind
152 )
153 .increment(1);
154 }
155 }
156}
157
158// ── Project create helper ─────────────────────────────────────────────────
159
160#[derive(Debug, Clone, Copy)]
161pub enum ProjectCreateOutcome {
162 Success,
163 BadName,
164 BadDescription,
165 BadKey,
166 Forbidden,
167 Conflict,
168 NotFound,
169 Other,
170}
171
172impl ProjectCreateOutcome {
173 pub fn as_label(self) -> &'static str {
174 match self {
175 ProjectCreateOutcome::Success => "success",
176 ProjectCreateOutcome::BadName => "bad_name",
177 ProjectCreateOutcome::BadDescription => "bad_description",
178 ProjectCreateOutcome::BadKey => "bad_key",
179 ProjectCreateOutcome::Forbidden => "forbidden",
180 ProjectCreateOutcome::Conflict => "conflict",
181 ProjectCreateOutcome::NotFound => "not_found",
182 ProjectCreateOutcome::Other => "other",
183 }
184 }
185}
186
187pub fn record_project_create(outcome: ProjectCreateOutcome) {
188 counter!(
189 "project_usecase_invocations_total",
190 "feature" => FEATURE_PROJECTS_CREATE,
191 "outcome" => outcome.as_label()
192 )
193 .increment(1);
194}