1use rustlavel_core::events::{Event, Subscriber, subscribe};
13use rustlavel_core::{Config, Json};
14use rustlavel_http::plugin::{Plugin, Setup};
15use rustlavel_http::{Request, Response, Status};
16use std::collections::BTreeMap;
17use std::sync::{Arc, RwLock};
18
19pub const BUCKETS: &[f64] = &[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
25
26type Labels = BTreeMap<String, String>;
28
29struct Histogram {
30 counts: Vec<u64>,
32 sum: f64,
33 total: u64,
34}
35
36impl Default for Histogram {
39 fn default() -> Self {
40 Histogram { counts: vec![0; BUCKETS.len() + 1], sum: 0.0, total: 0 }
41 }
42}
43
44impl Histogram {
45 fn observe(&mut self, seconds: f64) {
46 let index = BUCKETS.iter().position(|edge| seconds <= *edge).unwrap_or(BUCKETS.len());
49 for count in self.counts.iter_mut().skip(index) {
50 *count += 1;
51 }
52 self.sum += seconds;
53 self.total += 1;
54 }
55}
56
57#[derive(Default)]
58struct Store {
59 counters: BTreeMap<(String, Labels), u64>,
60 histograms: BTreeMap<(String, Labels), Histogram>,
61 gauges: BTreeMap<(String, Labels), f64>,
62}
63
64#[derive(Clone, Default)]
66pub struct Registry {
67 inner: Arc<RwLock<Store>>,
68}
69
70impl Registry {
71 pub fn new() -> Self {
72 Registry::default()
73 }
74
75 pub fn increment(&self, name: &str, labels: &[(&str, &str)]) {
76 self.add(name, labels, 1);
77 }
78
79 pub fn add(&self, name: &str, labels: &[(&str, &str)], amount: u64) {
80 let mut store = self.inner.write().expect("metrics lock poisoned");
81 *store.counters.entry((name.to_string(), label_set(labels))).or_insert(0) += amount;
82 }
83
84 pub fn observe(&self, name: &str, labels: &[(&str, &str)], seconds: f64) {
85 let mut store = self.inner.write().expect("metrics lock poisoned");
86 store
87 .histograms
88 .entry((name.to_string(), label_set(labels)))
89 .or_default()
90 .observe(seconds);
91 }
92
93 pub fn set(&self, name: &str, labels: &[(&str, &str)], value: f64) {
94 let mut store = self.inner.write().expect("metrics lock poisoned");
95 store.gauges.insert((name.to_string(), label_set(labels)), value);
96 }
97
98 pub fn counter(&self, name: &str, labels: &[(&str, &str)]) -> u64 {
99 let store = self.inner.read().expect("metrics lock poisoned");
100 store.counters.get(&(name.to_string(), label_set(labels))).copied().unwrap_or(0)
101 }
102
103 pub fn observations(&self, name: &str, labels: &[(&str, &str)]) -> u64 {
105 let store = self.inner.read().expect("metrics lock poisoned");
106 store
107 .histograms
108 .get(&(name.to_string(), label_set(labels)))
109 .map(|histogram| histogram.total)
110 .unwrap_or(0)
111 }
112
113 pub fn clear(&self) {
114 let mut store = self.inner.write().expect("metrics lock poisoned");
115 *store = Store::default();
116 }
117
118 pub fn render(&self) -> String {
120 let store = self.inner.read().expect("metrics lock poisoned");
121 let mut out = String::new();
122 let mut described: Vec<&str> = Vec::new();
123
124 for ((name, labels), value) in &store.counters {
125 if !described.contains(&name.as_str()) {
126 out.push_str(&format!("# TYPE {name} counter\n"));
127 described.push(name);
128 }
129 out.push_str(&format!("{name}{} {value}\n", render_labels(labels, None)));
130 }
131
132 for ((name, labels), value) in &store.gauges {
133 if !described.contains(&name.as_str()) {
134 out.push_str(&format!("# TYPE {name} gauge\n"));
135 described.push(name);
136 }
137 out.push_str(&format!("{name}{} {value}\n", render_labels(labels, None)));
138 }
139
140 for ((name, labels), histogram) in &store.histograms {
141 if !described.contains(&name.as_str()) {
142 out.push_str(&format!("# TYPE {name} histogram\n"));
143 described.push(name);
144 }
145 for (index, edge) in BUCKETS.iter().enumerate() {
146 out.push_str(&format!(
147 "{name}_bucket{} {}\n",
148 render_labels(labels, Some(&edge.to_string())),
149 histogram.counts[index]
150 ));
151 }
152 out.push_str(&format!(
153 "{name}_bucket{} {}\n",
154 render_labels(labels, Some("+Inf")),
155 histogram.counts[BUCKETS.len()]
156 ));
157 out.push_str(&format!("{name}_sum{} {}\n", render_labels(labels, None), histogram.sum));
158 out.push_str(&format!(
159 "{name}_count{} {}\n",
160 render_labels(labels, None),
161 histogram.total
162 ));
163 }
164
165 out
166 }
167}
168
169fn label_set(labels: &[(&str, &str)]) -> Labels {
170 labels.iter().map(|(name, value)| (name.to_string(), value.to_string())).collect()
171}
172
173fn render_labels(labels: &Labels, bucket: Option<&str>) -> String {
174 if labels.is_empty() && bucket.is_none() {
175 return String::new();
176 }
177
178 let mut parts: Vec<String> = labels
179 .iter()
180 .map(|(name, value)| format!("{name}=\"{}\"", escape(value)))
181 .collect();
182 if let Some(edge) = bucket {
183 parts.push(format!("le=\"{edge}\""));
184 }
185 format!("{{{}}}", parts.join(","))
186}
187
188fn escape(value: &str) -> String {
190 value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
191}
192
193struct Collector {
195 registry: Registry,
196}
197
198impl Subscriber for Collector {
199 fn interested_in(&self, kind: &str) -> bool {
200 matches!(kind, "http.request" | "db.query" | "queue.processed" | "queue.failed" | "ai.call")
201 }
202
203 fn handle(&self, event: &Event) {
204 let seconds = event.duration.map(|d| d.as_secs_f64()).unwrap_or(0.0);
205
206 match event.kind {
207 "http.request" => {
208 let route = event
211 .field("route")
212 .and_then(Json::as_str)
213 .or_else(|| event.field("path").and_then(Json::as_str))
214 .unwrap_or("unknown")
215 .to_string();
216 let method =
217 event.field("method").and_then(Json::as_str).unwrap_or("GET").to_string();
218 let status = event
219 .field("status")
220 .and_then(Json::as_i64)
221 .map(|code| code.to_string())
222 .unwrap_or_else(|| "0".into());
223
224 let labels = [("method", method.as_str()), ("route", route.as_str())];
225 self.registry.observe("rustlavel_http_request_duration_seconds", &labels, seconds);
226 self.registry.increment(
227 "rustlavel_http_requests_total",
228 &[
229 ("method", method.as_str()),
230 ("route", route.as_str()),
231 ("status", status.as_str()),
232 ],
233 );
234 }
235 "db.query" => {
236 let ok = event.field("ok").and_then(Json::as_bool).unwrap_or(true);
237 self.registry.observe("rustlavel_db_query_duration_seconds", &[], seconds);
238 self.registry
239 .increment("rustlavel_db_queries_total", &[("ok", if ok { "true" } else { "false" })]);
240 }
241 "queue.processed" | "queue.failed" => {
242 let job = event.field("job").and_then(Json::as_str).unwrap_or("unknown").to_string();
243 let outcome = if event.kind == "queue.failed" { "failed" } else { "processed" };
244 self.registry.increment(
245 "rustlavel_queue_jobs_total",
246 &[("job", job.as_str()), ("outcome", outcome)],
247 );
248 if event.kind == "queue.processed" {
249 self.registry.observe(
250 "rustlavel_queue_job_duration_seconds",
251 &[("job", job.as_str())],
252 seconds,
253 );
254 }
255 }
256 "ai.call" => {
257 let provider =
258 event.field("provider").and_then(Json::as_str).unwrap_or("unknown").to_string();
259 let model = event.field("model").and_then(Json::as_str).unwrap_or("").to_string();
260 let labels = [("provider", provider.as_str()), ("model", model.as_str())];
261
262 self.registry.increment("rustlavel_ai_calls_total", &labels);
263 self.registry.observe("rustlavel_ai_call_duration_seconds", &labels, seconds);
264 for (field, name) in
265 [("input_tokens", "rustlavel_ai_input_tokens_total"), ("output_tokens", "rustlavel_ai_output_tokens_total")]
266 {
267 if let Some(tokens) = event.field(field).and_then(Json::as_i64) {
268 self.registry.add(name, &labels, tokens.max(0) as u64);
269 }
270 }
271 }
272 _ => {}
273 }
274 }
275}
276
277pub struct Metrics {
279 path: String,
280 registry: Registry,
281}
282
283impl Default for Metrics {
284 fn default() -> Self {
285 Metrics { path: "/metrics".into(), registry: Registry::new() }
286 }
287}
288
289impl Metrics {
290 pub fn new() -> Self {
291 Metrics::default()
292 }
293
294 pub fn at(mut self, path: &str) -> Self {
295 self.path = path.to_string();
296 self
297 }
298
299 pub fn from_config(config: &Config) -> Self {
300 Metrics::new().at(&config.string("metrics.route", "/metrics"))
301 }
302
303 pub fn registry(&self) -> Registry {
305 self.registry.clone()
306 }
307
308 pub fn collect_only(&self) {
311 subscribe(Collector { registry: self.registry.clone() });
312 }
313}
314
315impl Plugin for Metrics {
316 fn name(&self) -> &'static str {
317 "metrics"
318 }
319
320 fn register(self: Box<Self>, setup: &mut Setup<'_>) {
321 let registry = self.registry.clone();
322 subscribe(Collector { registry: registry.clone() });
323
324 setup.state(registry.clone());
326
327 setup.router.get(&self.path, move |_request: Request| {
328 let registry = registry.clone();
329 async move {
330 Response::new(Status::OK)
331 .with_header("content-type", "text/plain; version=0.0.4; charset=utf-8")
334 .with_body(registry.render())
335 }
336 });
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use rustlavel_core::events::clear_subscribers;
344 use rustlavel_http::{Router, TestClient};
345
346 static BUS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
351
352 #[test]
353 fn counters_render_with_their_labels() {
354 let registry = Registry::new();
355 registry.increment("http_requests_total", &[("method", "GET"), ("status", "200")]);
356 registry.increment("http_requests_total", &[("method", "GET"), ("status", "200")]);
357
358 let rendered = registry.render();
359 assert!(rendered.contains("# TYPE http_requests_total counter"));
360 assert!(rendered.contains(r#"http_requests_total{method="GET",status="200"} 2"#));
361 }
362
363 #[test]
364 fn histogram_buckets_are_cumulative() {
365 let registry = Registry::new();
366 registry.observe("latency_seconds", &[], 0.003);
367 registry.observe("latency_seconds", &[], 0.4);
368
369 let rendered = registry.render();
370 assert!(rendered.contains(r#"latency_seconds_bucket{le="0.005"} 1"#));
372 assert!(rendered.contains(r#"latency_seconds_bucket{le="0.5"} 2"#));
373 assert!(rendered.contains(r#"latency_seconds_bucket{le="+Inf"} 2"#));
374 assert!(rendered.contains("latency_seconds_count 2"));
375 }
376
377 #[test]
378 fn an_observation_past_every_bucket_still_counts() {
379 let registry = Registry::new();
380 registry.observe("slow_seconds", &[], 30.0);
381
382 let rendered = registry.render();
383 assert!(rendered.contains(r#"slow_seconds_bucket{le="10"} 0"#));
384 assert!(rendered.contains(r#"slow_seconds_bucket{le="+Inf"} 1"#));
385 }
386
387 #[test]
388 fn label_values_are_escaped() {
389 let registry = Registry::new();
390 registry.increment("thing_total", &[("label", "a\"b\\c")]);
391
392 assert!(registry.render().contains(r#"label="a\"b\\c""#));
393 }
394
395 #[test]
396 fn a_gauge_replaces_rather_than_accumulates() {
397 let registry = Registry::new();
398 registry.set("workers", &[], 3.0);
399 registry.set("workers", &[], 5.0);
400
401 assert!(registry.render().contains("workers 5"));
402 }
403
404 #[tokio::test]
405 async fn requests_are_counted_by_route_not_by_path() {
406 let _guard = BUS.lock().await;
407 clear_subscribers();
408
409 let mut router = Router::new();
410 router.get("/orders/{id}", |_req: Request| async { "order" });
411
412 let metrics = Metrics::new();
413 let registry = metrics.registry();
414 let mut context = Some(rustlavel_core::Context::builder());
415 let config = Config::new();
416 let mut setup = Setup { router: &mut router, config: &config, context: &mut context };
417 Box::new(metrics).register(&mut setup);
418
419 let client = TestClient::new(router);
420 client.get("/orders/1").await.assert_ok();
421 client.get("/orders/2").await.assert_ok();
422 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
423
424 let rendered = client.get("/metrics").await.assert_ok().body();
425
426 assert!(
429 rendered.contains(r#"route="/orders/{id}",status="200"} 2"#),
430 "{rendered}"
431 );
432 assert!(!rendered.contains("/orders/1"));
433 assert_eq!(
434 registry.observations(
435 "rustlavel_http_request_duration_seconds",
436 &[("method", "GET"), ("route", "/orders/{id}")]
437 ),
438 2
439 );
440 clear_subscribers();
441 }
442
443 #[tokio::test]
444 async fn the_endpoint_advertises_the_exposition_format() {
445 let _guard = BUS.lock().await;
446 clear_subscribers();
447
448 let mut router = Router::new();
449 let config = Config::new();
450 let mut context = Some(rustlavel_core::Context::builder());
451 let mut setup = Setup { router: &mut router, config: &config, context: &mut context };
452 Box::new(Metrics::new().at("/internal/metrics")).register(&mut setup);
453
454 TestClient::new(router)
455 .get("/internal/metrics")
456 .await
457 .assert_ok()
458 .assert_header("content-type", "text/plain; version=0.0.4; charset=utf-8");
459
460 clear_subscribers();
461 }
462
463 #[tokio::test]
464 async fn database_and_ai_events_become_metrics() {
465 let _guard = BUS.lock().await;
466 clear_subscribers();
467
468 let registry = Registry::new();
469 subscribe(Collector { registry: registry.clone() });
470
471 Event::new("db.query")
472 .with("sql", "select 1")
473 .with("ok", true)
474 .took(std::time::Duration::from_millis(4))
475 .dispatch();
476 Event::new("ai.call")
477 .with("provider", "anthropic")
478 .with("model", "claude-sonnet-5")
479 .with("input_tokens", 120)
480 .with("output_tokens", 40)
481 .took(std::time::Duration::from_millis(900))
482 .dispatch();
483
484 assert_eq!(registry.counter("rustlavel_db_queries_total", &[("ok", "true")]), 1);
485
486 let ai = [("provider", "anthropic"), ("model", "claude-sonnet-5")];
487 assert_eq!(registry.counter("rustlavel_ai_calls_total", &ai), 1);
488 assert_eq!(registry.counter("rustlavel_ai_input_tokens_total", &ai), 120);
489 assert_eq!(registry.counter("rustlavel_ai_output_tokens_total", &ai), 40);
490
491 clear_subscribers();
492 }
493}