Skip to content
Clone

feat(issues): add metrics module and handler middleware

zhenyi committed
parent 564e6da commit 2f87d8d Browse files

3 files changed

+319 −0

File tree 3
Added lib/issues/handler/middleware/metrics.rs View lib/issues/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 `issues` crate.
6///
7/// Emits `issues_http_requests_total{route,method,status_class}` (counter) and
8/// `issues_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 channel-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 "issues_http_requests_total",
33 "route" => route.clone(),
34 "method" => method.clone(),
35 "status_class" => class
36 )
37 .increment(1);
38 metrics::histogram!(
39 "issues_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/issues/handler/middleware/mod.rs View lib/issues/handler/middleware/mod.rs
@@ -0,0 +1 @@
1pub(crate) mod metrics;
Added lib/issues/metrics.rs View lib/issues/metrics.rs
@@ -0,0 +1,271 @@
1//! Tracing layer for the `issues` 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 `channel_id`/`issue_id`/`user_id`).
6
7use std::time::Instant;
8
9use metrics::{counter, describe_counter, describe_histogram, histogram};
10
11use crate::error::IssueError;
12
13// ── Feature constants (one per usecase) ───────────────────────────────────
14
15// issue
16pub const FEATURE_ISSUES_CREATE: &str = "issues_create";
17pub const FEATURE_ISSUES_GET: &str = "issues_get";
18pub const FEATURE_ISSUES_UPDATE: &str = "issues_update";
19pub const FEATURE_ISSUES_DELETE: &str = "issues_delete";
20pub const FEATURE_ISSUES_LIST: &str = "issues_list";
21pub const FEATURE_ISSUES_CLOSE: &str = "issues_close";
22
23// issues (collection board)
24pub const FEATURE_ISSUE_BOARDS_CREATE: &str = "issue_boards_create";
25pub const FEATURE_ISSUE_BOARDS_GET: &str = "issue_boards_get";
26pub const FEATURE_ISSUE_BOARDS_UPDATE: &str = "issue_boards_update";
27pub const FEATURE_ISSUE_BOARDS_DELETE: &str = "issue_boards_delete";
28pub const FEATURE_ISSUE_BOARDS_LIST: &str = "issue_boards_list";
29
30// comment
31pub const FEATURE_COMMENTS_CREATE: &str = "comments_create";
32pub const FEATURE_COMMENTS_GET: &str = "comments_get";
33pub const FEATURE_COMMENTS_UPDATE: &str = "comments_update";
34pub const FEATURE_COMMENTS_DELETE: &str = "comments_delete";
35pub const FEATURE_COMMENTS_LIST: &str = "comments_list";
36
37// issue_attachment
38pub const FEATURE_ISSUE_ATTACHMENTS_CREATE: &str = "issue_attachments_create";
39pub const FEATURE_ISSUE_ATTACHMENTS_DELETE: &str = "issue_attachments_delete";
40pub const FEATURE_ISSUE_ATTACHMENTS_LIST: &str = "issue_attachments_list";
41
42// issue_content_history
43pub const FEATURE_ISSUE_CONTENT_HISTORY_LIST: &str = "issue_content_history_list";
44
45// issue_dependency
46pub const FEATURE_ISSUE_DEPENDENCIES_CREATE: &str = "issue_dependencies_create";
47pub const FEATURE_ISSUE_DEPENDENCIES_DELETE: &str = "issue_dependencies_delete";
48pub const FEATURE_ISSUE_DEPENDENCIES_LIST: &str = "issue_dependencies_list";
49
50// issue_event
51pub const FEATURE_ISSUE_EVENTS_LIST: &str = "issue_events_list";
52
53// issue_member
54pub const FEATURE_ISSUE_MEMBERS_CREATE: &str = "issue_members_create";
55pub const FEATURE_ISSUE_MEMBERS_DELETE: &str = "issue_members_delete";
56pub const FEATURE_ISSUE_MEMBERS_LIST: &str = "issue_members_list";
57
58// issue_pin
59pub const FEATURE_ISSUE_PINS_CREATE: &str = "issue_pins_create";
60pub const FEATURE_ISSUE_PINS_DELETE: &str = "issue_pins_delete";
61pub const FEATURE_ISSUE_PINS_LIST: &str = "issue_pins_list";
62
63// issue_reaction
64pub const FEATURE_ISSUE_REACTIONS_CREATE: &str = "issue_reactions_create";
65pub const FEATURE_ISSUE_REACTIONS_DELETE: &str = "issue_reactions_delete";
66pub const FEATURE_ISSUE_REACTIONS_LIST: &str = "issue_reactions_list";
67
68// issue_repo_rel
69pub const FEATURE_ISSUE_REPO_REL_CREATE: &str = "issue_repo_rel_create";
70pub const FEATURE_ISSUE_REPO_REL_DELETE: &str = "issue_repo_rel_delete";
71pub const FEATURE_ISSUE_REPO_REL_LIST: &str = "issue_repo_rel_list";
72
73// issue_stopwatch
74pub const FEATURE_ISSUE_STOPWATCH_START: &str = "issue_stopwatch_start";
75pub const FEATURE_ISSUE_STOPWATCH_DELETE: &str = "issue_stopwatch_delete";
76
77// issue_tracked_time
78pub const FEATURE_ISSUE_TRACKED_TIME_LIST: &str = "issue_tracked_time_list";
79
80// issue_user
81pub const FEATURE_ISSUE_USERS_LIST: &str = "issue_users_list";
82pub const FEATURE_ISSUE_USERS_UPDATE: &str = "issue_users_update";
83
84// issue_watch
85pub const FEATURE_ISSUE_WATCHES_UPSERT: &str = "issue_watches_upsert";
86pub const FEATURE_ISSUE_WATCHES_DELETE: &str = "issue_watches_delete";
87
88// label
89pub const FEATURE_LABELS_CREATE: &str = "labels_create";
90pub const FEATURE_LABELS_GET: &str = "labels_get";
91pub const FEATURE_LABELS_UPDATE: &str = "labels_update";
92pub const FEATURE_LABELS_DELETE: &str = "labels_delete";
93pub const FEATURE_LABELS_LIST: &str = "labels_list";
94pub const FEATURE_LABELS_BIND: &str = "labels_bind";
95pub const FEATURE_LABELS_UNBIND: &str = "labels_unbind";
96pub const FEATURE_LABELS_LIST_FOR_ISSUE: &str = "labels_list_for_issue";
97
98// milestone
99pub const FEATURE_MILESTONES_CREATE: &str = "milestones_create";
100pub const FEATURE_MILESTONES_GET: &str = "milestones_get";
101pub const FEATURE_MILESTONES_UPDATE: &str = "milestones_update";
102pub const FEATURE_MILESTONES_DELETE: &str = "milestones_delete";
103pub const FEATURE_MILESTONES_LIST: &str = "milestones_list";
104
105// ── Outcome labels (bounded enums) ────────────────────────────────────────
106
107pub const OUTCOME_SUCCESS: &str = "success";
108
109// ── Metric description registration (Once) ────────────────────────────────
110
111static DESCRIBE_ONCE: std::sync::Once = std::sync::Once::new();
112
113pub fn ensure_described() {
114 DESCRIBE_ONCE.call_once(describe_all);
115}
116
117fn describe_all() {
118 describe_counter!(
119 "issues_http_requests_total",
120 "Total HTTP requests handled by the issues service"
121 );
122 describe_histogram!(
123 "issues_http_request_duration_seconds",
124 metrics::Unit::Seconds,
125 "Issues HTTP request latency"
126 );
127 describe_counter!(
128 "issues_usecase_invocations_total",
129 "Total usecase invocations labeled by feature and outcome"
130 );
131 describe_counter!(
132 "issues_usecase_errors_total",
133 "Total usecase errors labeled by feature and error_kind"
134 );
135 describe_histogram!(
136 "issues_usecase_duration_seconds",
137 metrics::Unit::Seconds,
138 "Usecase execution latency"
139 );
140 describe_counter!(
141 "issues_storage_operations_total",
142 "Object storage operations initiated by the issues service"
143 );
144}
145
146// ── Timer (RAII) ──────────────────────────────────────────────────────────
147
148/// Records the usecase execution duration on drop.
149pub struct Timer {
150 pub feature: &'static str,
151 pub start: Instant,
152}
153
154impl Timer {
155 pub fn start(feature: &'static str) -> Self {
156 ensure_described();
157 Self {
158 feature,
159 start: Instant::now(),
160 }
161 }
162}
163
164impl Drop for Timer {
165 fn drop(&mut self) {
166 let elapsed = self.start.elapsed().as_secs_f64();
167 histogram!(
168 "issues_usecase_duration_seconds",
169 "feature" => self.feature.to_string()
170 )
171 .record(elapsed);
172 }
173}
174
175// ── Generic outcome counter ───────────────────────────────────────────────
176
177/// Bump invocation counter (always) and, on `Err`, an error counter labeled
178/// by `IssueError::error_kind()`. Drop semantics of `_timer` capture latency.
179pub fn record_outcome<T>(feature: &'static str, result: &Result<T, IssueError>) {
180 match result {
181 Ok(_) => {
182 counter!(
183 "issues_usecase_invocations_total",
184 "feature" => feature.to_string(),
185 "outcome" => OUTCOME_SUCCESS
186 )
187 .increment(1);
188 }
189 Err(e) => {
190 let kind = e.error_kind();
191 counter!(
192 "issues_usecase_invocations_total",
193 "feature" => feature.to_string(),
194 "outcome" => kind
195 )
196 .increment(1);
197 counter!(
198 "issues_usecase_errors_total",
199 "feature" => feature.to_string(),
200 "outcome" => kind
201 )
202 .increment(1);
203 }
204 }
205}
206
207// ── Issue create helper ───────────────────────────────────────────────────
208
209#[derive(Debug, Clone, Copy)]
210pub enum IssueCreateOutcome {
211 Success,
212 BadTitle,
213 BadBody,
214 ChannelNotFound,
215 Forbidden,
216 Other,
217}
218
219impl IssueCreateOutcome {
220 pub fn as_label(self) -> &'static str {
221 match self {
222 IssueCreateOutcome::Success => "success",
223 IssueCreateOutcome::BadTitle => "bad_title",
224 IssueCreateOutcome::BadBody => "bad_body",
225 IssueCreateOutcome::ChannelNotFound => "channel_not_found",
226 IssueCreateOutcome::Forbidden => "forbidden",
227 IssueCreateOutcome::Other => "other",
228 }
229 }
230}
231
232pub fn record_issue_create(outcome: IssueCreateOutcome) {
233 counter!(
234 "issues_usecase_invocations_total",
235 "feature" => FEATURE_ISSUES_CREATE,
236 "outcome" => outcome.as_label()
237 )
238 .increment(1);
239}
240
241// ── Issue reaction helper ─────────────────────────────────────────────────
242
243#[derive(Debug, Clone, Copy)]
244pub enum IssueReactionOutcome {
245 Success,
246 AlreadyExists,
247 Forbidden,
248 NotFound,
249 Other,
250}
251
252impl IssueReactionOutcome {
253 pub fn as_label(self) -> &'static str {
254 match self {
255 IssueReactionOutcome::Success => "success",
256 IssueReactionOutcome::AlreadyExists => "already_exists",
257 IssueReactionOutcome::Forbidden => "forbidden",
258 IssueReactionOutcome::NotFound => "not_found",
259 IssueReactionOutcome::Other => "other",
260 }
261 }
262}
263
264pub fn record_issue_reaction(outcome: IssueReactionOutcome) {
265 counter!(
266 "issues_usecase_invocations_total",
267 "feature" => FEATURE_ISSUE_REACTIONS_CREATE,
268 "outcome" => outcome.as_label()
269 )
270 .increment(1);
271}