Skip to main content

rustlavel_metrics/
lib.rs

1//! rustlavel-metrics: Prometheus metrics, gathered from what the framework
2//! already reports.
3//!
4//! Nothing has to be instrumented twice. Every package dispatches on the
5//! instrumentation bus; this listens and keeps counters and histograms, and
6//! serves them in the text format Prometheus scrapes.
7//!
8//! ```ignore
9//! App::new()?.plugin(Metrics::default())   // exposes /metrics
10//! ```
11
12use 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
19/// The bucket boundaries, in seconds.
20///
21/// Chosen around what a web request actually looks like: most under 100ms, a
22/// long tail that matters. Prometheus needs the boundaries fixed up front,
23/// which is why they are a constant rather than computed.
24pub 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
26/// A label set, kept ordered so one series always renders identically.
27type Labels = BTreeMap<String, String>;
28
29struct Histogram {
30    /// Counts per bucket, aligned with [`BUCKETS`], plus a final +Inf bucket.
31    counts: Vec<u64>,
32    sum: f64,
33    total: u64,
34}
35
36/// Sized to the bucket boundaries, because an empty `counts` would panic on the
37/// first observation — the one place a derived `Default` would be wrong.
38impl 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        // Prometheus histograms are cumulative: an observation lands in its own
47        // bucket and every wider one.
48        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/// The collected metrics, cheap to clone.
65#[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    /// Total observations recorded for a histogram.
104    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    /// Render the Prometheus text exposition format.
119    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
188/// A label value may contain anything, and unescaped it would break the format.
189fn escape(value: &str) -> String {
190    value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
191}
192
193/// Turns framework events into metrics.
194struct 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                // Labelled by route pattern, not path: one series per route
209                // rather than one per id, which is what keeps cardinality sane.
210                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
277/// Exposes `/metrics` and starts collecting.
278pub 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    /// The registry, so an application can record its own metrics too.
304    pub fn registry(&self) -> Registry {
305        self.registry.clone()
306    }
307
308    /// Start collecting without mounting a route — for a worker process, which
309    /// has metrics worth pushing but no HTTP server.
310    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        // Registered as state so an application can record its own counters.
325        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                    // The exposition format's own content type; Prometheus
332                    // accepts text/plain but this is what it advertises.
333                    .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    /// The event bus is process-wide, so tests that use it run one at a time.
347    ///
348    /// A tokio mutex rather than a std one: these tests await while holding it,
349    /// which a std guard must never do.
350    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        // The 5ms bucket holds only the fast one; +Inf holds both.
371        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        // Two different ids, one series — otherwise every id becomes a
427        // timeseries and the scrape falls over.
428        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}