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/// Lane p0-39: entries displaced out of a per-participant retention window.
69///
70/// ADDITIVE, and it has to be: the event it counts did not exist before. The
71/// stage-8 receipt windows used to REFUSE at their bound, which was loud on the
72/// wire (a typed `ReceiptCapacityExceeded` the client could see). They now
73/// displace instead, which is silent to the arriving client BY DESIGN — the
74/// (N+1)th honest fingerprint lands. A bound that neither refuses nor discloses
75/// would hide exactly what the old wall at least made loud, so displacement is
76/// silent to experience and loud to record: this counter is the record.
77const RECEIPT_DISPLACEMENTS_TOTAL: &str = "liminal_receipt_displacements_total";
78/// Lane p0-39: observations that a SHARED receipt pool is carrying a churn
79/// storm (`liminal_receipt_pool_runaway_total`).
80///
81/// The three shared pools stopped being admission gates entirely — no
82/// configured number may refuse an honest third party there — so their only
83/// bound is the TTL window. This counter is the tripwire that replaces the
84/// wall: it is an OCCUPANCY OBSERVATION, never a gate, and nothing is refused
85/// because it moved. It increments once per admitted operation that observed a
86/// pool at or above its configured reporting threshold, so its rate is the
87/// storm's rate.
88const RECEIPT_POOL_RUNAWAY_TOTAL: &str = "liminal_receipt_pool_runaway_total";
89/// Label key carried by [`RECEIPT_DISPLACEMENTS_TOTAL`]. Cardinality is bounded
90/// by [`ReceiptWindowScope`]'s two variants.
91const SCOPE_LABEL: &str = "scope";
92/// Label key carried by [`RECEIPT_POOL_RUNAWAY_TOTAL`]. Cardinality is bounded
93/// by [`SharedReceiptPool`]'s three variants.
94const POOL_LABEL: &str = "pool";
95
96/// The per-participant retention windows that can displace an older entry.
97///
98/// A fixed, enum-derived label vocabulary: these are the only two scopes whose
99/// configured number is a window size rather than a gate.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub(crate) enum ReceiptWindowScope {
102 /// Stage-8 `LiveReceiptParticipant`.
103 LiveReceiptParticipant,
104 /// Stage-8 `ProvenanceParticipant`.
105 ProvenanceParticipant,
106}
107
108impl ReceiptWindowScope {
109 /// Every window scope, in handle-storage order.
110 pub(crate) const LABELS: [&'static str; 2] =
111 ["live_receipt_participant", "provenance_participant"];
112
113 const fn slot(self) -> usize {
114 match self {
115 Self::LiveReceiptParticipant => 0,
116 Self::ProvenanceParticipant => 1,
117 }
118 }
119}
120
121/// The shared receipt pools whose retention is TTL-bounded and tripwired.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub(crate) enum SharedReceiptPool {
124 /// Stage-8 `LiveReceiptServer`.
125 LiveReceiptServer,
126 /// Stage-8 `ProvenanceServer`.
127 ProvenanceServer,
128 /// Stage-8 `ProvenanceConversation`.
129 ProvenanceConversation,
130}
131
132impl SharedReceiptPool {
133 /// Every shared pool, in handle-storage order.
134 pub(crate) const LABELS: [&'static str; 3] = [
135 "live_receipt_server",
136 "provenance_server",
137 "provenance_conversation",
138 ];
139
140 const fn slot(self) -> usize {
141 match self {
142 Self::LiveReceiptServer => 0,
143 Self::ProvenanceServer => 1,
144 Self::ProvenanceConversation => 2,
145 }
146 }
147
148 /// Human-readable pool name for the paired rising-edge warning.
149 pub(crate) const fn label(self) -> &'static str {
150 Self::LABELS[self.slot()]
151 }
152}
153
154static SERVER_METRICS: OnceLock<ServerMetrics> = OnceLock::new();
155
156/// Cached handles for the first-wave server metrics.
157#[derive(Clone, Debug)]
158struct ServerMetrics {
159 connections_active: GaugeHandle,
160 publishes_total: CounterHandle,
161 deliveries_total: CounterHandle,
162 /// One pre-registered handle per [`MountKind`], so the delivery hot path
163 /// looks a counter up by enum discriminant and never registers, allocates a
164 /// label string, or hashes a name mid-slice.
165 transport_deliveries: [CounterHandle; MOUNT_KINDS.len()],
166 sheds_total: CounterHandle,
167 /// One pre-registered handle per admission-refusal class, so the refusal
168 /// path looks a counter up by discriminant and never allocates a label
169 /// string while it is busy turning a connection away.
170 admission_refusals: [CounterHandle; AdmissionRefusal::LABELS.len()],
171 /// One pre-registered handle per WebSocket upgrade-refusal class.
172 handshake_refusals: [CounterHandle; UpgradeRefusal::LABELS.len()],
173 /// One pre-registered handle per per-participant retention window.
174 receipt_displacements: [CounterHandle; ReceiptWindowScope::LABELS.len()],
175 /// One pre-registered handle per shared receipt pool.
176 receipt_pool_runaway: [CounterHandle; SharedReceiptPool::LABELS.len()],
177}
178
179/// Every transport a delivery can ride, in the order their handles are stored.
180const MOUNT_KINDS: [MountKind; 3] = [MountKind::Tcp, MountKind::WebSocket, MountKind::Loopback];
181
182const fn transport_slot(transport: MountKind) -> usize {
183 match transport {
184 MountKind::Tcp => 0,
185 MountKind::WebSocket => 1,
186 MountKind::Loopback => 2,
187 }
188}
189
190/// Enables metrics for this server process and registers the server families.
191///
192/// Idempotent: a second call is a no-op. Called once at server startup so the
193/// `/metrics` endpoint has data to render; the recording helpers below stay
194/// inert until this runs.
195pub fn init() {
196 if SERVER_METRICS.get().is_some() {
197 return;
198 }
199 let Some(registry) = global_or_install() else {
200 return;
201 };
202 if let Some(metrics) = ServerMetrics::register(registry) {
203 let _ = SERVER_METRICS.set(metrics);
204 }
205}
206
207/// Records the spawn of a supervised connection (`liminal_connections_active`
208/// gauge increment). Paired with [`connection_closed`] on every teardown route.
209pub fn connection_spawned() {
210 if let Some(metrics) = SERVER_METRICS.get() {
211 metrics.connections_active.increment();
212 }
213}
214
215/// Records the teardown of a supervised connection (`liminal_connections_active`
216/// gauge decrement). Paired with [`connection_spawned`].
217pub fn connection_closed() {
218 if let Some(metrics) = SERVER_METRICS.get() {
219 metrics.connections_active.decrement();
220 }
221}
222
223/// Records one accepted publish on the services publish path
224/// (`liminal_publishes_total`).
225pub fn publish_accepted() {
226 if let Some(metrics) = SERVER_METRICS.get() {
227 metrics.publishes_total.increment();
228 }
229}
230
231/// Records `count` genuine subscriber deliveries from a single publish
232/// (`liminal_deliveries_total`). A publish that reached no subscriber records
233/// nothing.
234pub fn deliveries_recorded(count: u64) {
235 if count == 0 {
236 return;
237 }
238 if let Some(metrics) = SERVER_METRICS.get() {
239 metrics.deliveries_total.increment_by(count);
240 }
241}
242
243/// Records pump deliveries on `transport` (`liminal_transport_deliveries_total`).
244///
245/// `count` is the number of `Deliver` frames one connection slice enqueued. A
246/// slice that delivered nothing records nothing, so an idle connection touches no
247/// atomic at all.
248pub fn transport_deliveries_recorded(transport: MountKind, count: u64) {
249 if count == 0 {
250 return;
251 }
252 if let Some(metrics) = SERVER_METRICS.get() {
253 metrics.transport_deliveries[transport_slot(transport)].increment_by(count);
254 }
255}
256
257/// Records one connection turned away at the admission door
258/// (`liminal_admission_refusals_total`, labelled by reason class).
259///
260/// Called at the three admission doors — TCP accept, WebSocket upgrade, and the
261/// in-process loopback — and nowhere else, so a refusal is counted exactly once
262/// no matter which mount knocked.
263pub(crate) fn admission_refused(refusal: AdmissionRefusal) {
264 if let Some(metrics) = SERVER_METRICS.get() {
265 metrics.admission_refusals[refusal.slot()].increment();
266 }
267}
268
269/// Records one WebSocket upgrade refused before admission was attempted
270/// (`liminal_handshake_refusals_total`, labelled by reason class).
271pub(crate) fn handshake_refused(refusal: &UpgradeRefusal) {
272 if let Some(metrics) = SERVER_METRICS.get() {
273 metrics.handshake_refusals[refusal.slot()].increment();
274 }
275}
276
277/// Records one subscription shed by an inbox overflow
278/// (`liminal_subscription_sheds_total`).
279///
280/// Paired at its only call site with the `warn` that names the channel,
281/// subscription and transport: the counter is what an alert fires on, the log
282/// line is what the alert is then read against.
283pub fn subscription_shed() {
284 if let Some(metrics) = SERVER_METRICS.get() {
285 metrics.sheds_total.increment();
286 }
287}
288
289/// Records `count` entries displaced out of one per-participant retention
290/// window (`liminal_receipt_displacements_total`, labelled by scope).
291///
292/// The arriving client sees nothing — that is the ruled behaviour, "silent to
293/// experience" — so this counter and the `debug` line beside it are the only
294/// record that a bound did work. A zero count returns early, so the ordinary
295/// with-headroom path touches no atomic.
296pub(crate) fn receipt_entries_displaced(scope: ReceiptWindowScope, count: u64) {
297 if count == 0 {
298 return;
299 }
300 if let Some(metrics) = SERVER_METRICS.get() {
301 metrics.receipt_displacements[scope.slot()].increment_by(count);
302 }
303}
304
305/// Records one observation of a shared receipt pool at or above its configured
306/// reporting threshold (`liminal_receipt_pool_runaway_total`, labelled by pool).
307///
308/// An OBSERVATION, not a refusal: the operation that made it was admitted. The
309/// rising-edge `warn` beside the first such observation carries the occupancy
310/// and threshold; this counter carries the storm's rate.
311pub(crate) fn receipt_pool_runaway_observed(pool: SharedReceiptPool) {
312 if let Some(metrics) = SERVER_METRICS.get() {
313 metrics.receipt_pool_runaway[pool.slot()].increment();
314 }
315}
316
317/// The current value of the accepted-publish counter, for a test that needs an
318/// UNRELATED counter to prove its harness measured anything at all.
319///
320/// `None` means the family is not readable — either [`init`] has not run in this
321/// process or the registry holds no such counter — which a caller must treat as
322/// "no measurement", never as zero. The name is read from the same constant the
323/// registration uses, so the two cannot drift.
324#[cfg(test)]
325pub(crate) fn publishes_total_value() -> Option<u64> {
326 use liminal::metrics::MetricValue;
327
328 let registry = global_registry()?;
329 registry
330 .snapshot()
331 .metrics()
332 .iter()
333 .find(|metric| metric.name == PUBLISHES_TOTAL)
334 .and_then(|metric| match metric.value {
335 MetricValue::Counter(value) => Some(value),
336 MetricValue::Gauge(_) | MetricValue::Histogram(_) => None,
337 })
338}
339
340/// The current value of one lane p0-39 displacement counter, by scope.
341///
342/// `None` means the family is not readable — either [`init`] has not run in
343/// this process or the registry holds no such counter — which a caller must
344/// treat as "no measurement", never as zero. Name and label are read from the
345/// same constants the registration uses, so a rename shows up as a failure
346/// rather than as a vacuous pass.
347#[cfg(test)]
348pub(crate) fn receipt_displacements_value(scope: ReceiptWindowScope) -> Option<u64> {
349 labelled_counter_value(
350 RECEIPT_DISPLACEMENTS_TOTAL,
351 SCOPE_LABEL,
352 ReceiptWindowScope::LABELS[scope.slot()],
353 )
354}
355
356/// The current value of one lane p0-39 shared-pool tripwire counter, by pool.
357///
358/// `None` means "not measured", never zero — see
359/// [`receipt_displacements_value`].
360#[cfg(test)]
361pub(crate) fn receipt_pool_runaway_value(pool: SharedReceiptPool) -> Option<u64> {
362 labelled_counter_value(RECEIPT_POOL_RUNAWAY_TOTAL, POOL_LABEL, pool.label())
363}
364
365#[cfg(test)]
366fn labelled_counter_value(name: &str, key: &str, label: &str) -> Option<u64> {
367 use liminal::metrics::MetricValue;
368
369 let registry = global_registry()?;
370 registry
371 .snapshot()
372 .metrics()
373 .iter()
374 .find(|metric| {
375 metric.name == name
376 && metric
377 .labels
378 .iter()
379 .any(|(metric_key, value)| metric_key == key && value == label)
380 })
381 .and_then(|metric| match metric.value {
382 MetricValue::Counter(value) => Some(value),
383 MetricValue::Gauge(_) | MetricValue::Histogram(_) => None,
384 })
385}
386
387impl ServerMetrics {
388 fn register(registry: &MetricsRegistry) -> Option<Self> {
389 let connections_active = registry
390 .register_gauge(CONNECTIONS_ACTIVE, no_labels())
391 .ok()?;
392 let publishes_total = registry
393 .register_counter(PUBLISHES_TOTAL, no_labels())
394 .ok()?;
395 let deliveries_total = registry
396 .register_counter(DELIVERIES_TOTAL, no_labels())
397 .ok()?;
398 let mut transport_deliveries = Vec::with_capacity(MOUNT_KINDS.len());
399 for transport in MOUNT_KINDS {
400 transport_deliveries.push(
401 registry
402 .register_counter(
403 TRANSPORT_DELIVERIES_TOTAL,
404 [(TRANSPORT_LABEL, transport.as_str())],
405 )
406 .ok()?,
407 );
408 }
409 let transport_deliveries: [CounterHandle; MOUNT_KINDS.len()] =
410 transport_deliveries.try_into().ok()?;
411 let sheds_total = registry.register_counter(SHEDS_TOTAL, no_labels()).ok()?;
412 let admission_refusals = register_labelled(
413 registry,
414 ADMISSION_REFUSALS_TOTAL,
415 REASON_LABEL,
416 &AdmissionRefusal::LABELS,
417 )?;
418 let handshake_refusals = register_labelled(
419 registry,
420 HANDSHAKE_REFUSALS_TOTAL,
421 REASON_LABEL,
422 &UpgradeRefusal::LABELS,
423 )?;
424 let receipt_displacements = register_labelled(
425 registry,
426 RECEIPT_DISPLACEMENTS_TOTAL,
427 SCOPE_LABEL,
428 &ReceiptWindowScope::LABELS,
429 )?;
430 let receipt_pool_runaway = register_labelled(
431 registry,
432 RECEIPT_POOL_RUNAWAY_TOTAL,
433 POOL_LABEL,
434 &SharedReceiptPool::LABELS,
435 )?;
436 Some(Self {
437 connections_active,
438 publishes_total,
439 deliveries_total,
440 transport_deliveries,
441 sheds_total,
442 admission_refusals,
443 handshake_refusals,
444 receipt_displacements,
445 receipt_pool_runaway,
446 })
447 }
448}
449
450/// Pre-registers one counter per label value, in label order.
451///
452/// Every class is registered at `init`, not lazily on first refusal, so the
453/// exposition carries an explicit zero for classes that have not fired. A
454/// missing line and a zero line mean very different things to an operator: the
455/// first is "this server does not know about that failure mode", the second is
456/// "it has not happened".
457fn register_labelled<const N: usize>(
458 registry: &MetricsRegistry,
459 name: &'static str,
460 key: &'static str,
461 labels: &[&'static str; N],
462) -> Option<[CounterHandle; N]> {
463 let mut handles = Vec::with_capacity(N);
464 for label in labels {
465 handles.push(registry.register_counter(name, [(key, *label)]).ok()?);
466 }
467 handles.try_into().ok()
468}
469
470const fn no_labels() -> std::iter::Empty<(&'static str, &'static str)> {
471 std::iter::empty()
472}
473
474/// Returns the process-global registry, installing a fresh one when none exists.
475///
476/// Enabling the gate here (rather than in the library) keeps standalone liminal
477/// users on the disabled fast path; the server is the sole installer.
478fn global_or_install() -> Option<&'static MetricsRegistry> {
479 if let Some(registry) = global_registry() {
480 return Some(registry);
481 }
482 // Best-effort install; if a concurrent caller won the race we still read the
483 // now-installed registry back below.
484 let _ = install_global_registry(MetricsRegistry::new());
485 global_registry()
486}
487
488#[cfg(test)]
489mod tests {
490 use super::{
491 CONNECTIONS_ACTIVE, DELIVERIES_TOTAL, PUBLISHES_TOTAL, connection_spawned,
492 deliveries_recorded, init, publish_accepted,
493 };
494 use liminal::metrics::{global_registry, render};
495
496 #[test]
497 fn init_registers_the_three_server_families_on_the_global_registry()
498 -> Result<(), Box<dyn std::error::Error>> {
499 init();
500 connection_spawned();
501 publish_accepted();
502 deliveries_recorded(2);
503
504 let registry =
505 global_registry().ok_or("init must install and enable the global registry")?;
506 let exposition = render(®istry.snapshot());
507
508 assert!(exposition.contains(CONNECTIONS_ACTIVE));
509 assert!(exposition.contains(PUBLISHES_TOTAL));
510 assert!(exposition.contains(DELIVERIES_TOTAL));
511
512 Ok(())
513 }
514}