liminal_server/metrics.rs
1//! Server-side metric recording over the process-global liminal registry.
2//!
3//! [`init`] installs a global [`MetricsRegistry`] — which flips the library's
4//! metrics gate on for this process — and registers the first-wave server
5//! families, caching their handles. Every recording helper no-ops until `init`
6//! has run, so a standalone liminal library user (who never calls `init`) pays
7//! nothing and the registry stays disabled.
8
9use std::sync::OnceLock;
10
11use liminal::metrics::{
12 CounterHandle, GaugeHandle, MetricsRegistry, global_registry, install_global_registry,
13};
14
15use crate::server::connection::refusal::AdmissionRefusal;
16use crate::server::connection::websocket::UpgradeRefusal;
17use crate::server::mount::MountKind;
18
19const CONNECTIONS_ACTIVE: &str = "liminal_connections_active";
20const PUBLISHES_TOTAL: &str = "liminal_publishes_total";
21const DELIVERIES_TOTAL: &str = "liminal_deliveries_total";
22/// Second-wave: deliveries split by the transport that carried them.
23///
24/// ADDITIVE. [`DELIVERIES_TOTAL`] keeps its meaning and its exposition line
25/// unchanged — an operator's existing `/metrics` scrape sees every line it saw
26/// before, plus these. The two families count different things and are not
27/// expected to agree: the aggregate counts subscriber deliveries accepted at
28/// PUBLISH time (channel-actor fan-out), this one counts `Deliver` frames the
29/// per-connection pump actually enqueued toward a socket.
30const TRANSPORT_DELIVERIES_TOTAL: &str = "liminal_transport_deliveries_total";
31/// Second-wave: subscriptions shed by an inbox overflow (P0 #55).
32///
33/// Deliberately UNLABELLED. A per-channel or per-subscription shed counter is
34/// unbounded cardinality on a surface a scraper keeps forever; the channel and
35/// subscription of any individual shed are on the `warn` line the shed emits
36/// beside this increment, where they cost one log record rather than a permanent
37/// time series.
38const SHEDS_TOTAL: &str = "liminal_subscription_sheds_total";
39/// Label key carried by [`TRANSPORT_DELIVERIES_TOTAL`]. Cardinality is bounded by
40/// [`MountKind`]'s variants, which is why THIS split is affordable and a
41/// per-channel one is not.
42const TRANSPORT_LABEL: &str = "transport";
43/// P0 #56: connections turned away at the admission door, by reason class.
44///
45/// ADDITIVE, and it has to be, because no existing family can answer this.
46/// [`CONNECTIONS_ACTIVE`] is incremented in `register_record`
47/// (`server/connection/supervisor.rs:2822`), which is reached only AFTER
48/// admission has succeeded — a refusal is exactly the case where it never runs.
49/// On a server refusing every connection that gauge therefore reads a flat
50/// zero, indistinguishable from a healthy idle server, which is what the field
51/// estate's dashboards showed while 82,166 consecutive connections were turned
52/// away. This counter can only move when a connection was refused.
53const ADMISSION_REFUSALS_TOTAL: &str = "liminal_admission_refusals_total";
54/// P0 #56: WebSocket upgrades refused before admission was even attempted.
55///
56/// Separate from [`ADMISSION_REFUSALS_TOTAL`] because it counts a different
57/// event at a different door: these connections never reached the shared
58/// admission bound, the incarnation authority, or the registry. Folding them
59/// together would make an origin-policy rejection look like a capacity problem.
60const HANDSHAKE_REFUSALS_TOTAL: &str = "liminal_handshake_refusals_total";
61/// Label key carried by both refusal families.
62///
63/// Cardinality is bounded by the variant count of `AdmissionRefusal` and
64/// `UpgradeRefusal` respectively — fixed, enum-derived strings. A peer address,
65/// an origin value, or an error message here would be unbounded cardinality on
66/// a surface a scraper keeps forever.
67const REASON_LABEL: &str = "reason";
68
69static SERVER_METRICS: OnceLock<ServerMetrics> = OnceLock::new();
70
71/// Cached handles for the first-wave server metrics.
72#[derive(Clone, Debug)]
73struct ServerMetrics {
74 connections_active: GaugeHandle,
75 publishes_total: CounterHandle,
76 deliveries_total: CounterHandle,
77 /// One pre-registered handle per [`MountKind`], so the delivery hot path
78 /// looks a counter up by enum discriminant and never registers, allocates a
79 /// label string, or hashes a name mid-slice.
80 transport_deliveries: [CounterHandle; MOUNT_KINDS.len()],
81 sheds_total: CounterHandle,
82 /// One pre-registered handle per admission-refusal class, so the refusal
83 /// path looks a counter up by discriminant and never allocates a label
84 /// string while it is busy turning a connection away.
85 admission_refusals: [CounterHandle; AdmissionRefusal::LABELS.len()],
86 /// One pre-registered handle per WebSocket upgrade-refusal class.
87 handshake_refusals: [CounterHandle; UpgradeRefusal::LABELS.len()],
88}
89
90/// Every transport a delivery can ride, in the order their handles are stored.
91const MOUNT_KINDS: [MountKind; 3] = [MountKind::Tcp, MountKind::WebSocket, MountKind::Loopback];
92
93const fn transport_slot(transport: MountKind) -> usize {
94 match transport {
95 MountKind::Tcp => 0,
96 MountKind::WebSocket => 1,
97 MountKind::Loopback => 2,
98 }
99}
100
101/// Enables metrics for this server process and registers the server families.
102///
103/// Idempotent: a second call is a no-op. Called once at server startup so the
104/// `/metrics` endpoint has data to render; the recording helpers below stay
105/// inert until this runs.
106pub fn init() {
107 if SERVER_METRICS.get().is_some() {
108 return;
109 }
110 let Some(registry) = global_or_install() else {
111 return;
112 };
113 if let Some(metrics) = ServerMetrics::register(registry) {
114 let _ = SERVER_METRICS.set(metrics);
115 }
116}
117
118/// Records the spawn of a supervised connection (`liminal_connections_active`
119/// gauge increment). Paired with [`connection_closed`] on every teardown route.
120pub fn connection_spawned() {
121 if let Some(metrics) = SERVER_METRICS.get() {
122 metrics.connections_active.increment();
123 }
124}
125
126/// Records the teardown of a supervised connection (`liminal_connections_active`
127/// gauge decrement). Paired with [`connection_spawned`].
128pub fn connection_closed() {
129 if let Some(metrics) = SERVER_METRICS.get() {
130 metrics.connections_active.decrement();
131 }
132}
133
134/// Records one accepted publish on the services publish path
135/// (`liminal_publishes_total`).
136pub fn publish_accepted() {
137 if let Some(metrics) = SERVER_METRICS.get() {
138 metrics.publishes_total.increment();
139 }
140}
141
142/// Records `count` genuine subscriber deliveries from a single publish
143/// (`liminal_deliveries_total`). A publish that reached no subscriber records
144/// nothing.
145pub fn deliveries_recorded(count: u64) {
146 if count == 0 {
147 return;
148 }
149 if let Some(metrics) = SERVER_METRICS.get() {
150 metrics.deliveries_total.increment_by(count);
151 }
152}
153
154/// Records pump deliveries on `transport` (`liminal_transport_deliveries_total`).
155///
156/// `count` is the number of `Deliver` frames one connection slice enqueued. A
157/// slice that delivered nothing records nothing, so an idle connection touches no
158/// atomic at all.
159pub fn transport_deliveries_recorded(transport: MountKind, count: u64) {
160 if count == 0 {
161 return;
162 }
163 if let Some(metrics) = SERVER_METRICS.get() {
164 metrics.transport_deliveries[transport_slot(transport)].increment_by(count);
165 }
166}
167
168/// Records one connection turned away at the admission door
169/// (`liminal_admission_refusals_total`, labelled by reason class).
170///
171/// Called at the three admission doors — TCP accept, WebSocket upgrade, and the
172/// in-process loopback — and nowhere else, so a refusal is counted exactly once
173/// no matter which mount knocked.
174pub(crate) fn admission_refused(refusal: AdmissionRefusal) {
175 if let Some(metrics) = SERVER_METRICS.get() {
176 metrics.admission_refusals[refusal.slot()].increment();
177 }
178}
179
180/// Records one WebSocket upgrade refused before admission was attempted
181/// (`liminal_handshake_refusals_total`, labelled by reason class).
182pub(crate) fn handshake_refused(refusal: &UpgradeRefusal) {
183 if let Some(metrics) = SERVER_METRICS.get() {
184 metrics.handshake_refusals[refusal.slot()].increment();
185 }
186}
187
188/// Records one subscription shed by an inbox overflow
189/// (`liminal_subscription_sheds_total`).
190///
191/// Paired at its only call site with the `warn` that names the channel,
192/// subscription and transport: the counter is what an alert fires on, the log
193/// line is what the alert is then read against.
194pub fn subscription_shed() {
195 if let Some(metrics) = SERVER_METRICS.get() {
196 metrics.sheds_total.increment();
197 }
198}
199
200/// The current value of the accepted-publish counter, for a test that needs an
201/// UNRELATED counter to prove its harness measured anything at all.
202///
203/// `None` means the family is not readable — either [`init`] has not run in this
204/// process or the registry holds no such counter — which a caller must treat as
205/// "no measurement", never as zero. The name is read from the same constant the
206/// registration uses, so the two cannot drift.
207#[cfg(test)]
208pub(crate) fn publishes_total_value() -> Option<u64> {
209 use liminal::metrics::MetricValue;
210
211 let registry = global_registry()?;
212 registry
213 .snapshot()
214 .metrics()
215 .iter()
216 .find(|metric| metric.name == PUBLISHES_TOTAL)
217 .and_then(|metric| match metric.value {
218 MetricValue::Counter(value) => Some(value),
219 MetricValue::Gauge(_) | MetricValue::Histogram(_) => None,
220 })
221}
222
223impl ServerMetrics {
224 fn register(registry: &MetricsRegistry) -> Option<Self> {
225 let connections_active = registry
226 .register_gauge(CONNECTIONS_ACTIVE, no_labels())
227 .ok()?;
228 let publishes_total = registry
229 .register_counter(PUBLISHES_TOTAL, no_labels())
230 .ok()?;
231 let deliveries_total = registry
232 .register_counter(DELIVERIES_TOTAL, no_labels())
233 .ok()?;
234 let mut transport_deliveries = Vec::with_capacity(MOUNT_KINDS.len());
235 for transport in MOUNT_KINDS {
236 transport_deliveries.push(
237 registry
238 .register_counter(
239 TRANSPORT_DELIVERIES_TOTAL,
240 [(TRANSPORT_LABEL, transport.as_str())],
241 )
242 .ok()?,
243 );
244 }
245 let transport_deliveries: [CounterHandle; MOUNT_KINDS.len()] =
246 transport_deliveries.try_into().ok()?;
247 let sheds_total = registry.register_counter(SHEDS_TOTAL, no_labels()).ok()?;
248 let admission_refusals = register_labelled(
249 registry,
250 ADMISSION_REFUSALS_TOTAL,
251 &AdmissionRefusal::LABELS,
252 )?;
253 let handshake_refusals =
254 register_labelled(registry, HANDSHAKE_REFUSALS_TOTAL, &UpgradeRefusal::LABELS)?;
255 Some(Self {
256 connections_active,
257 publishes_total,
258 deliveries_total,
259 transport_deliveries,
260 sheds_total,
261 admission_refusals,
262 handshake_refusals,
263 })
264 }
265}
266
267/// Pre-registers one counter per label value, in label order.
268///
269/// Every class is registered at `init`, not lazily on first refusal, so the
270/// exposition carries an explicit zero for classes that have not fired. A
271/// missing line and a zero line mean very different things to an operator: the
272/// first is "this server does not know about that failure mode", the second is
273/// "it has not happened".
274fn register_labelled<const N: usize>(
275 registry: &MetricsRegistry,
276 name: &'static str,
277 labels: &[&'static str; N],
278) -> Option<[CounterHandle; N]> {
279 let mut handles = Vec::with_capacity(N);
280 for label in labels {
281 handles.push(
282 registry
283 .register_counter(name, [(REASON_LABEL, *label)])
284 .ok()?,
285 );
286 }
287 handles.try_into().ok()
288}
289
290const fn no_labels() -> std::iter::Empty<(&'static str, &'static str)> {
291 std::iter::empty()
292}
293
294/// Returns the process-global registry, installing a fresh one when none exists.
295///
296/// Enabling the gate here (rather than in the library) keeps standalone liminal
297/// users on the disabled fast path; the server is the sole installer.
298fn global_or_install() -> Option<&'static MetricsRegistry> {
299 if let Some(registry) = global_registry() {
300 return Some(registry);
301 }
302 // Best-effort install; if a concurrent caller won the race we still read the
303 // now-installed registry back below.
304 let _ = install_global_registry(MetricsRegistry::new());
305 global_registry()
306}
307
308#[cfg(test)]
309mod tests {
310 use super::{
311 CONNECTIONS_ACTIVE, DELIVERIES_TOTAL, PUBLISHES_TOTAL, connection_spawned,
312 deliveries_recorded, init, publish_accepted,
313 };
314 use liminal::metrics::{global_registry, render};
315
316 #[test]
317 fn init_registers_the_three_server_families_on_the_global_registry()
318 -> Result<(), Box<dyn std::error::Error>> {
319 init();
320 connection_spawned();
321 publish_accepted();
322 deliveries_recorded(2);
323
324 let registry =
325 global_registry().ok_or("init must install and enable the global registry")?;
326 let exposition = render(®istry.snapshot());
327
328 assert!(exposition.contains(CONNECTIONS_ACTIVE));
329 assert!(exposition.contains(PUBLISHES_TOTAL));
330 assert!(exposition.contains(DELIVERIES_TOTAL));
331
332 Ok(())
333 }
334}