Skip to main content

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}