Skip to main content

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(&registry.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}