lightshuttle_control/metrics.rs
1//! Prometheus metrics for the control plane.
2//!
3//! Metrics are exposed in the Prometheus text exposition format on
4//! `GET /metrics`. The recorder is installed once per process by
5//! [`Metrics::install`]; tests build a non-installing handle via
6//! [`Metrics::for_test`] so multiple control servers can coexist
7//! without panicking on a double global install.
8
9use std::time::Instant;
10
11use metrics::{counter, describe_counter, describe_gauge, describe_histogram, gauge, histogram};
12use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
13
14/// Counter incremented on every accepted restart request.
15pub(crate) const RESTART_TOTAL: &str = "lightshuttle_restart_total";
16
17/// Histogram of the seconds a resource takes to go from started to
18/// healthy.
19pub(crate) const EVENT_DURATION: &str = "lightshuttle_lifecycle_event_duration_seconds";
20
21/// Gauge of resource count, labelled by status.
22const RESOURCES: &str = "lightshuttle_resources";
23
24/// Gauge of orchestrator uptime in seconds.
25const UPTIME: &str = "lightshuttle_uptime_seconds";
26
27/// Prometheus metrics handle for the control plane.
28///
29/// Wraps a [`PrometheusHandle`] and the process start time used to
30/// compute `lightshuttle_uptime_seconds` at scrape time.
31///
32/// # Lifecycle
33///
34/// Call [`Metrics::install`] **once** per process to register the global
35/// Prometheus recorder. Pass the resulting value (wrapped in an
36/// [`std::sync::Arc`]) to [`crate::ControlState::with_metrics`] so
37/// `GET /metrics` can render a live snapshot.
38///
39/// For tests and embedders that do not need metrics, build with
40/// [`Metrics::for_test`], which does not touch the global recorder.
41///
42/// # Tracked metrics
43///
44/// | Metric name | Kind | Description |
45/// |---|---|---|
46/// | `lightshuttle_restart_total` | counter | Accepted restart requests |
47/// | `lightshuttle_lifecycle_event_duration_seconds` | histogram | Seconds from start to healthy |
48/// | `lightshuttle_resources` | gauge (per status label) | Managed resource count |
49/// | `lightshuttle_uptime_seconds` | gauge | Process uptime |
50pub struct Metrics {
51 handle: PrometheusHandle,
52 started: Instant,
53}
54
55impl Metrics {
56 /// Install the global Prometheus recorder and describe every
57 /// metric. Call exactly once per process, before any metric is
58 /// recorded.
59 ///
60 /// # Panics
61 ///
62 /// Panics if a global recorder is already installed.
63 #[must_use]
64 pub fn install() -> Self {
65 let handle = PrometheusBuilder::new()
66 .install_recorder()
67 .expect("failed to install the Prometheus recorder");
68 describe_metrics();
69 Self {
70 handle,
71 started: Instant::now(),
72 }
73 }
74
75 /// Build a non-installing handle for tests.
76 ///
77 /// The `metrics!` macros always target the globally installed
78 /// recorder, which this constructor never sets. The returned handle
79 /// therefore renders an empty snapshot regardless of any metric
80 /// recorded elsewhere. Use [`Self::install`] plus
81 /// [`super::ControlState::with_metrics`] to serve live metrics.
82 #[must_use]
83 pub fn for_test() -> Self {
84 let recorder = PrometheusBuilder::new().build_recorder();
85 let handle = recorder.handle();
86 Self {
87 handle,
88 started: Instant::now(),
89 }
90 }
91
92 /// Render the current metrics snapshot in Prometheus text format.
93 ///
94 /// Before serialising, this method refreshes the two scrape-time gauges:
95 /// - `lightshuttle_resources{status="<s>"}` for each `(status, count)` pair
96 /// in `status_counts`.
97 /// - `lightshuttle_uptime_seconds` derived from the process start time.
98 ///
99 /// The returned string is suitable for serving directly as the body of
100 /// `GET /metrics` with content type `text/plain; version=0.0.4`.
101 #[must_use]
102 pub fn render(&self, status_counts: &[(&str, u64)]) -> String {
103 for (status, count) in status_counts {
104 #[allow(clippy::cast_precision_loss)]
105 gauge!(RESOURCES, "status" => (*status).to_owned()).set(*count as f64);
106 }
107 #[allow(clippy::cast_precision_loss)]
108 gauge!(UPTIME).set(self.started.elapsed().as_secs_f64());
109 self.handle.render()
110 }
111}
112
113/// Increment the restart counter. Safe to call from anywhere once the
114/// recorder is installed; a no-op when no recorder is present.
115pub(crate) fn record_restart() {
116 counter!(RESTART_TOTAL).increment(1);
117}
118
119/// Record a sample in the `lightshuttle_lifecycle_event_duration_seconds`
120/// histogram.
121///
122/// `seconds` is the elapsed wall time from when the resource was started
123/// until it reached a healthy state. Safe to call from any thread once
124/// the global recorder is installed via [`Metrics::install`]. A no-op
125/// when no recorder is present (e.g. in tests built with
126/// [`Metrics::for_test`]).
127pub fn observe_event_duration(seconds: f64) {
128 histogram!(EVENT_DURATION).record(seconds);
129}
130
131fn describe_metrics() {
132 describe_counter!(RESTART_TOTAL, "Total number of accepted restart requests");
133 describe_histogram!(
134 EVENT_DURATION,
135 "Seconds a resource takes to go from started to healthy"
136 );
137 describe_gauge!(RESOURCES, "Number of managed resources, labelled by status");
138 describe_gauge!(UPTIME, "Orchestrator uptime in seconds");
139}