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::durability::{DedupHeadCounts, dedup_head_counts};
12use liminal::metrics::{
13    CounterHandle, GaugeHandle, MetricRegistrationError, MetricsRegistry, global_registry,
14    install_global_registry,
15};
16
17use crate::console::counts::ConsoleFailure;
18use crate::server::connection::refusal::AdmissionRefusal;
19use crate::server::connection::websocket::UpgradeRefusal;
20use crate::server::mount::MountKind;
21
22const CONNECTIONS_ACTIVE: &str = "liminal_connections_active";
23const PUBLISHES_TOTAL: &str = "liminal_publishes_total";
24const DELIVERIES_TOTAL: &str = "liminal_deliveries_total";
25/// Second-wave: deliveries split by the transport that carried them.
26///
27/// ADDITIVE. [`DELIVERIES_TOTAL`] keeps its meaning and its exposition line
28/// unchanged — an operator's existing `/metrics` scrape sees every line it saw
29/// before, plus these. The two families count different things and are not
30/// expected to agree: the aggregate counts subscriber deliveries accepted at
31/// PUBLISH time (channel-actor fan-out), this one counts `Deliver` frames the
32/// per-connection pump actually enqueued toward a socket.
33const TRANSPORT_DELIVERIES_TOTAL: &str = "liminal_transport_deliveries_total";
34/// Second-wave: subscriptions shed by an inbox overflow (P0 #55).
35///
36/// Deliberately UNLABELLED. A per-channel or per-subscription shed counter is
37/// unbounded cardinality on a surface a scraper keeps forever; the channel and
38/// subscription of any individual shed are on the `warn` line the shed emits
39/// beside this increment, where they cost one log record rather than a permanent
40/// time series.
41const SHEDS_TOTAL: &str = "liminal_subscription_sheds_total";
42const SELF_STREAM_APPEND_FAILURES_TOTAL: &str = "liminal_self_stream_append_failures_total";
43const SELF_STREAMS_RESERVED_PUBLICATIONS_TOTAL: &str =
44    "liminal_self_streams_reserved_publications_total";
45/// Starts that omitted self streams because no participant service was installed.
46const SELF_STREAMS_DISABLED_TOTAL: &str = "liminal_self_streams_disabled_total";
47/// SRV-012 R4: set to 1 when the console streams are off for want of a
48/// participant (no publisher owner runs them).
49const CONSOLE_STREAMS_DISABLED_TOTAL: &str = "liminal_console_streams_disabled_total";
50/// SRV-012 R8: console bookkeeping failures, by `kind` (bounded by
51/// `ConsoleFailure`'s variants).
52const CONSOLE_FAILURES_TOTAL: &str = "liminal_console_failures_total";
53const KIND_LABEL: &str = "kind";
54const SELF_STREAMS_UNMATCHED_PRESSURE_TOTAL: &str = "liminal_self_streams_unmatched_pressure_total";
55const SELF_STREAMS_SUPPRESSED_PRESSURE_TOTAL: &str =
56    "liminal_self_streams_suppressed_pressure_total";
57const SELF_STREAMS_PRESSURE_LEDGER_EVICTIONS_TOTAL: &str =
58    "liminal_self_streams_pressure_ledger_evictions_total";
59/// Backpressure signals (`Accept`/`Defer`/`Reject`) refused by the access
60/// decision on their subscription's subject: the signal has no refusal frame,
61/// so the count is the only place the refusal shows besides the log line.
62const PRESSURE_REFUSED_TOTAL: &str = "liminal_pressure_refused_total";
63/// ACCESS-011 R2: acts a missing grant would have refused, let through under
64/// `[auth.lys] decisions = "advise"`; the refusal counters' sibling.
65const ADVISED_TOTAL: &str = "liminal_advised_total";
66/// Label key carried by [`TRANSPORT_DELIVERIES_TOTAL`]. Cardinality is bounded by
67/// [`MountKind`]'s variants, which is why THIS split is affordable and a
68/// per-channel one is not.
69const TRANSPORT_LABEL: &str = "transport";
70/// P0 #56: connections turned away at the admission door, by reason class.
71///
72/// ADDITIVE, and it has to be, because no existing family can answer this.
73/// [`CONNECTIONS_ACTIVE`] is incremented in `register_record`
74/// (`server/connection/supervisor.rs:2822`), which is reached only AFTER
75/// admission has succeeded — a refusal is exactly the case where it never runs.
76/// On a server refusing every connection that gauge therefore reads a flat
77/// zero, indistinguishable from a healthy idle server, which is what the field
78/// estate's dashboards showed while 82,166 consecutive connections were turned
79/// away. This counter can only move when a connection was refused.
80const ADMISSION_REFUSALS_TOTAL: &str = "liminal_admission_refusals_total";
81/// P0 #56: WebSocket upgrades refused before admission was even attempted.
82///
83/// Separate from [`ADMISSION_REFUSALS_TOTAL`] because it counts a different
84/// event at a different door: these connections never reached the shared
85/// admission bound, the incarnation authority, or the registry. Folding them
86/// together would make an origin-policy rejection look like a capacity problem.
87const HANDSHAKE_REFUSALS_TOTAL: &str = "liminal_handshake_refusals_total";
88/// Label key carried by both refusal families.
89///
90/// Cardinality is bounded by the variant count of `AdmissionRefusal` and
91/// `UpgradeRefusal` respectively — fixed, enum-derived strings. A peer address,
92/// an origin value, or an error message here would be unbounded cardinality on
93/// a surface a scraper keeps forever.
94const REASON_LABEL: &str = "reason";
95/// Lane p0-39: entries displaced out of a per-participant retention window.
96///
97/// ADDITIVE, and it has to be: the event it counts did not exist before. The
98/// stage-8 receipt windows used to REFUSE at their bound, which was loud on the
99/// wire (a typed `ReceiptCapacityExceeded` the client could see). They now
100/// displace instead, which is silent to the arriving client BY DESIGN — the
101/// (N+1)th honest fingerprint lands. A bound that neither refuses nor discloses
102/// would hide exactly what the old wall at least made loud, so displacement is
103/// silent to experience and loud to record: this counter is the record.
104const RECEIPT_DISPLACEMENTS_TOTAL: &str = "liminal_receipt_displacements_total";
105/// Lane p0-39: observations that a SHARED receipt pool is carrying a churn
106/// storm (`liminal_receipt_pool_runaway_total`).
107///
108/// The three shared pools stopped being admission gates entirely — no
109/// configured number may refuse an honest third party there — so their only
110/// bound is the TTL window. This counter is the tripwire that replaces the
111/// wall: it is an OCCUPANCY OBSERVATION, never a gate, and nothing is refused
112/// because it moved. It increments once per admitted operation that observed a
113/// pool at or above its configured reporting threshold, so its rate is the
114/// storm's rate.
115const RECEIPT_POOL_RUNAWAY_TOTAL: &str = "liminal_receipt_pool_runaway_total";
116/// Connection fates currently parked on candidate-lane `Precedence`.
117///
118/// A parked fate holds a durable Open with no Complete. It waits on the drain of
119/// a lane another participant's terminal or a compaction marker occupies, and
120/// while it waits nothing on the wire shows it. A gauge that stays above zero is
121/// a conversation nobody has published into since, and a non-zero value at a
122/// clean stop is a stop that could not settle.
123const CONNECTION_FATES_PARKED: &str = "liminal_connection_fates_parked";
124/// Label key carried by [`RECEIPT_DISPLACEMENTS_TOTAL`]. Cardinality is bounded
125/// by [`ReceiptWindowScope`]'s two variants.
126const SCOPE_LABEL: &str = "scope";
127/// Label key carried by [`RECEIPT_POOL_RUNAWAY_TOTAL`]. Cardinality is bounded
128/// by [`SharedReceiptPool`]'s three variants.
129const POOL_LABEL: &str = "pool";
130
131/// The per-participant retention windows that can displace an older entry.
132///
133/// A fixed, enum-derived label vocabulary: these are the only two scopes whose
134/// configured number is a window size rather than a gate.
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub(crate) enum ReceiptWindowScope {
137    /// Stage-8 `LiveReceiptParticipant`.
138    LiveReceiptParticipant,
139    /// Stage-8 `ProvenanceParticipant`.
140    ProvenanceParticipant,
141}
142
143impl ReceiptWindowScope {
144    /// Every window scope, in handle-storage order.
145    pub(crate) const LABELS: [&'static str; 2] =
146        ["live_receipt_participant", "provenance_participant"];
147
148    const fn slot(self) -> usize {
149        match self {
150            Self::LiveReceiptParticipant => 0,
151            Self::ProvenanceParticipant => 1,
152        }
153    }
154}
155
156/// The shared receipt pools whose retention is TTL-bounded and tripwired.
157#[derive(Clone, Copy, Debug, PartialEq, Eq)]
158pub(crate) enum SharedReceiptPool {
159    /// Stage-8 `LiveReceiptServer`.
160    LiveReceiptServer,
161    /// Stage-8 `ProvenanceServer`.
162    ProvenanceServer,
163    /// Stage-8 `ProvenanceConversation`.
164    ProvenanceConversation,
165}
166
167impl SharedReceiptPool {
168    /// Every shared pool, in handle-storage order.
169    pub(crate) const LABELS: [&'static str; 3] = [
170        "live_receipt_server",
171        "provenance_server",
172        "provenance_conversation",
173    ];
174
175    const fn slot(self) -> usize {
176        match self {
177            Self::LiveReceiptServer => 0,
178            Self::ProvenanceServer => 1,
179            Self::ProvenanceConversation => 2,
180        }
181    }
182
183    /// Human-readable pool name for the paired rising-edge warning.
184    pub(crate) const fn label(self) -> &'static str {
185        Self::LABELS[self.slot()]
186    }
187}
188
189static SERVER_METRICS: OnceLock<ServerMetrics> = OnceLock::new();
190
191/// Cached handles for the first-wave server metrics.
192#[derive(Clone, Debug)]
193struct ServerMetrics {
194    connections_active: GaugeHandle,
195    connection_fates_parked: GaugeHandle,
196    publishes_total: CounterHandle,
197    deliveries_total: CounterHandle,
198    /// One pre-registered handle per [`MountKind`], so the delivery hot path
199    /// looks a counter up by enum discriminant and never registers, allocates a
200    /// label string, or hashes a name mid-slice.
201    transport_deliveries: [CounterHandle; MOUNT_KINDS.len()],
202    sheds_total: CounterHandle,
203    self_stream_append_failures_total: CounterHandle,
204    self_streams_disabled_total: CounterHandle,
205    console_streams_disabled_total: CounterHandle,
206    console_failures: [CounterHandle; ConsoleFailure::LABELS.len()],
207    self_streams_reserved_publications_total: CounterHandle,
208    self_streams_unmatched_pressure_total: CounterHandle,
209    self_streams_suppressed_pressure_total: CounterHandle,
210    self_streams_pressure_ledger_evictions_total: CounterHandle,
211    pressure_refused_total: CounterHandle,
212    advised_total: CounterHandle,
213    /// One pre-registered handle per admission-refusal class, so the refusal
214    /// path looks a counter up by discriminant and never allocates a label
215    /// string while it is busy turning a connection away.
216    admission_refusals: [CounterHandle; AdmissionRefusal::LABELS.len()],
217    /// One pre-registered handle per WebSocket upgrade-refusal class.
218    handshake_refusals: [CounterHandle; UpgradeRefusal::LABELS.len()],
219    /// One pre-registered handle per per-participant retention window.
220    receipt_displacements: [CounterHandle; ReceiptWindowScope::LABELS.len()],
221    /// One pre-registered handle per shared receipt pool.
222    receipt_pool_runaway: [CounterHandle; SharedReceiptPool::LABELS.len()],
223}
224
225/// Every transport a delivery can ride, in the order their handles are stored.
226const MOUNT_KINDS: [MountKind; 3] = [MountKind::Tcp, MountKind::WebSocket, MountKind::Loopback];
227
228const fn transport_slot(transport: MountKind) -> usize {
229    match transport {
230        MountKind::Tcp => 0,
231        MountKind::WebSocket => 1,
232        MountKind::Loopback => 2,
233    }
234}
235
236/// Enables metrics for this server process and registers the server families.
237///
238/// Idempotent: a second call is a no-op. Called once at server startup so the
239/// `/metrics` endpoint has data to render; the recording helpers below stay
240/// inert until this runs.
241pub fn init() {
242    if SERVER_METRICS.get().is_some() {
243        return;
244    }
245    let Some(registry) = global_or_install() else {
246        tracing::warn!(
247            "global metrics registry could not be installed or read back; server metrics stay disabled"
248        );
249        return;
250    };
251    if let Some(metrics) = ServerMetrics::register(registry) {
252        let _ = SERVER_METRICS.set(metrics);
253    }
254}
255
256/// Records the spawn of a supervised connection (`liminal_connections_active`
257/// gauge increment). Paired with [`connection_closed`] on every teardown route.
258pub fn connection_spawned() {
259    if let Some(metrics) = SERVER_METRICS.get() {
260        metrics.connections_active.increment();
261    }
262}
263
264/// Records the teardown of a supervised connection (`liminal_connections_active`
265/// gauge decrement). Paired with [`connection_spawned`].
266pub fn connection_closed() {
267    if let Some(metrics) = SERVER_METRICS.get() {
268        metrics.connections_active.decrement();
269    }
270}
271
272/// Records one connection fate parked on candidate-lane `Precedence`
273/// (`liminal_connection_fates_parked` gauge increment). Paired with
274/// [`connection_fate_unparked`] when the fate leaves the index.
275pub(crate) fn connection_fate_parked() {
276    if let Some(metrics) = SERVER_METRICS.get() {
277        metrics.connection_fates_parked.increment();
278    }
279}
280
281/// Records one parked connection fate leaving the index for its retry
282/// (`liminal_connection_fates_parked` gauge decrement).
283pub(crate) fn connection_fate_unparked() {
284    if let Some(metrics) = SERVER_METRICS.get() {
285        metrics.connection_fates_parked.decrement();
286    }
287}
288
289/// Records one accepted publish on the services publish path
290/// (`liminal_publishes_total`).
291pub fn publish_accepted() {
292    if let Some(metrics) = SERVER_METRICS.get() {
293        metrics.publishes_total.increment();
294    }
295}
296
297/// Records `count` genuine subscriber deliveries from a single publish
298/// (`liminal_deliveries_total`). A publish that reached no subscriber records
299/// nothing.
300pub fn deliveries_recorded(count: u64) {
301    if count == 0 {
302        return;
303    }
304    if let Some(metrics) = SERVER_METRICS.get() {
305        metrics.deliveries_total.increment_by(count);
306    }
307}
308
309/// Records pump deliveries on `transport` (`liminal_transport_deliveries_total`).
310///
311/// `count` is the number of `Deliver` frames one connection slice enqueued. A
312/// slice that delivered nothing records nothing, so an idle connection touches no
313/// atomic at all.
314pub fn transport_deliveries_recorded(transport: MountKind, count: u64) {
315    if count == 0 {
316        return;
317    }
318    if let Some(metrics) = SERVER_METRICS.get() {
319        metrics.transport_deliveries[transport_slot(transport)].increment_by(count);
320    }
321}
322
323/// Records one connection turned away at the admission door
324/// (`liminal_admission_refusals_total`, labelled by reason class).
325///
326/// Called at the three admission doors — TCP accept, WebSocket upgrade, and the
327/// in-process loopback — and nowhere else, so a refusal is counted exactly once
328/// no matter which mount knocked.
329pub(crate) fn admission_refused(refusal: AdmissionRefusal) {
330    if let Some(metrics) = SERVER_METRICS.get() {
331        metrics.admission_refusals[refusal.slot()].increment();
332    }
333}
334
335/// Records one WebSocket upgrade refused before admission was attempted
336/// (`liminal_handshake_refusals_total`, labelled by reason class).
337pub(crate) fn handshake_refused(refusal: &UpgradeRefusal) {
338    if let Some(metrics) = SERVER_METRICS.get() {
339        metrics.handshake_refusals[refusal.slot()].increment();
340    }
341}
342
343pub(crate) fn self_stream_append_failed() {
344    if let Some(metrics) = SERVER_METRICS.get() {
345        metrics.self_stream_append_failures_total.increment();
346    }
347}
348
349pub(crate) fn self_stream_reserved_publication() {
350    if let Some(metrics) = SERVER_METRICS.get() {
351        metrics.self_streams_reserved_publications_total.increment();
352    }
353}
354
355pub(crate) fn self_streams_disabled() {
356    if let Some(metrics) = SERVER_METRICS.get() {
357        metrics.self_streams_disabled_total.increment();
358    }
359}
360
361/// SRV-012 R8: `amount` console bookkeeping failures of `kind`.
362pub(crate) fn console_failure(kind: ConsoleFailure, amount: u64) {
363    if let Some(metrics) = SERVER_METRICS.get() {
364        metrics.console_failures[kind.slot()].increment_by(amount);
365    }
366}
367
368/// SRV-012 R4: the console streams are off for want of a participant.
369pub(crate) fn console_streams_disabled() {
370    if let Some(metrics) = SERVER_METRICS.get() {
371        metrics.console_streams_disabled_total.increment();
372    }
373}
374
375pub(crate) fn self_stream_pressure_unmatched() {
376    if let Some(metrics) = SERVER_METRICS.get() {
377        metrics.self_streams_unmatched_pressure_total.increment();
378    }
379}
380
381pub(crate) fn self_stream_pressure_suppressed() {
382    if let Some(metrics) = SERVER_METRICS.get() {
383        metrics.self_streams_suppressed_pressure_total.increment();
384    }
385}
386
387pub(crate) fn pressure_refused() {
388    if let Some(metrics) = SERVER_METRICS.get() {
389        metrics.pressure_refused_total.increment();
390    }
391}
392
393/// Records one act advised through a missing grant (`liminal_advised_total`).
394pub(crate) fn advised() {
395    if let Some(metrics) = SERVER_METRICS.get() {
396        metrics.advised_total.increment();
397    }
398}
399
400pub(crate) fn pressure_ledger_evicted() {
401    if let Some(metrics) = SERVER_METRICS.get() {
402        metrics
403            .self_streams_pressure_ledger_evictions_total
404            .increment();
405    }
406}
407
408/// Records one subscription shed by an inbox overflow
409/// (`liminal_subscription_sheds_total`).
410///
411/// Paired at its only call site with the `warn` that names the channel,
412/// subscription and transport: the counter is what an alert fires on, the log
413/// line is what the alert is then read against.
414pub fn subscription_shed() {
415    if let Some(metrics) = SERVER_METRICS.get() {
416        metrics.sheds_total.increment();
417    }
418}
419
420/// Records `count` entries displaced out of one per-participant retention
421/// window (`liminal_receipt_displacements_total`, labelled by scope).
422///
423/// The arriving client sees nothing — that is the ruled behaviour, "silent to
424/// experience" — so this counter and the `debug` line beside it are the only
425/// record that a bound did work. A zero count returns early, so the ordinary
426/// with-headroom path touches no atomic.
427pub(crate) fn receipt_entries_displaced(scope: ReceiptWindowScope, count: u64) {
428    if count == 0 {
429        return;
430    }
431    if let Some(metrics) = SERVER_METRICS.get() {
432        metrics.receipt_displacements[scope.slot()].increment_by(count);
433    }
434}
435
436/// Records one observation of a shared receipt pool at or above its configured
437/// reporting threshold (`liminal_receipt_pool_runaway_total`, labelled by pool).
438///
439/// An OBSERVATION, not a refusal: the operation that made it was admitted. The
440/// rising-edge `warn` beside the first such observation carries the occupancy
441/// and threshold; this counter carries the storm's rate.
442pub(crate) fn receipt_pool_runaway_observed(pool: SharedReceiptPool) {
443    if let Some(metrics) = SERVER_METRICS.get() {
444        metrics.receipt_pool_runaway[pool.slot()].increment();
445    }
446}
447
448/// Appends the dedup head counters (DUR-008 A12) to a `/metrics` body: one
449/// unlabelled counter family per name of
450/// [`DedupHeadCounts::named`](liminal::durability::DedupHeadCounts::named),
451/// all three from one [`dedup_head_counts`] read.
452///
453/// Rendered at SCRAPE time, never registered. The counters are the library's
454/// process-global atomics, moved on the dedup lookup path by one relaxed
455/// `fetch_add` with no registry in reach; a registry handle mirroring them
456/// would need that path to bump a second atomic, and `liminal::durability` to
457/// know the server's registry, or a sampler copying them on a clock. Read here
458/// they cost three relaxed loads per scrape, take no lock, and touch nothing a
459/// publish or a dedup lookup holds.
460///
461/// The names are the library's own, iterated rather than retyped, so the
462/// exposition cannot drift from them. They carry the server's `liminal_`
463/// prefix and the counter `_total` suffix; no registry family is named
464/// `liminal_dedup_head_*`, so no family is declared twice. The header lines
465/// are the ones `liminal::metrics::render` writes.
466pub(crate) fn render_dedup_head_counters(output: &mut String) {
467    render_dedup_head_block(output, dedup_head_counts());
468}
469
470/// The exposition block for one reading of the dedup head counters: for each
471/// counter, in [`DedupHeadCounts::named`]'s order, its `# HELP` line, its
472/// `# TYPE` line and its value line.
473fn render_dedup_head_block(output: &mut String, counts: DedupHeadCounts) {
474    for (name, value) in counts.named() {
475        let value = value.to_string();
476        for piece in [
477            "# HELP ",
478            name,
479            " ",
480            name,
481            "\n# TYPE ",
482            name,
483            " counter\n",
484            name,
485            " ",
486            &value,
487            "\n",
488        ] {
489            output.push_str(piece);
490        }
491    }
492}
493
494/// The current value of the accepted-publish counter, for a test that needs an
495/// UNRELATED counter to prove its harness measured anything at all.
496///
497/// `None` means the family is not readable — either [`init`] has not run in this
498/// process or the registry holds no such counter — which a caller must treat as
499/// "no measurement", never as zero. The name is read from the same constant the
500/// registration uses, so the two cannot drift.
501#[cfg(test)]
502pub(crate) fn publishes_total_value() -> Option<u64> {
503    use liminal::metrics::MetricValue;
504
505    let registry = global_registry()?;
506    registry
507        .snapshot()
508        .metrics()
509        .iter()
510        .find(|metric| metric.name == PUBLISHES_TOTAL)
511        .and_then(|metric| match metric.value {
512            MetricValue::Counter(value) => Some(value),
513            MetricValue::Gauge(_) | MetricValue::Histogram(_) => None,
514        })
515}
516
517/// The current value of one lane p0-39 displacement counter, by scope.
518///
519/// `None` means the family is not readable — either [`init`] has not run in
520/// this process or the registry holds no such counter — which a caller must
521/// treat as "no measurement", never as zero. Name and label are read from the
522/// same constants the registration uses, so a rename shows up as a failure
523/// rather than as a vacuous pass.
524#[cfg(test)]
525pub(crate) fn receipt_displacements_value(scope: ReceiptWindowScope) -> Option<u64> {
526    labelled_counter_value(
527        RECEIPT_DISPLACEMENTS_TOTAL,
528        SCOPE_LABEL,
529        ReceiptWindowScope::LABELS[scope.slot()],
530    )
531}
532
533/// The current value of one lane p0-39 shared-pool tripwire counter, by pool.
534///
535/// `None` means "not measured", never zero — see
536/// [`receipt_displacements_value`].
537#[cfg(test)]
538pub(crate) fn receipt_pool_runaway_value(pool: SharedReceiptPool) -> Option<u64> {
539    labelled_counter_value(RECEIPT_POOL_RUNAWAY_TOTAL, POOL_LABEL, pool.label())
540}
541
542/// The current value of the parked connection-fate gauge.
543///
544/// `None` means "not measured", never zero — see
545/// [`receipt_displacements_value`].
546#[cfg(test)]
547pub(crate) fn connection_fates_parked_value() -> Option<i64> {
548    use liminal::metrics::MetricValue;
549
550    let registry = global_registry()?;
551    registry
552        .snapshot()
553        .metrics()
554        .iter()
555        .find(|metric| metric.name == CONNECTION_FATES_PARKED)
556        .and_then(|metric| match metric.value {
557            MetricValue::Gauge(value) => Some(value),
558            MetricValue::Counter(_) | MetricValue::Histogram(_) => None,
559        })
560}
561
562#[cfg(test)]
563fn labelled_counter_value(name: &str, key: &str, label: &str) -> Option<u64> {
564    use liminal::metrics::MetricValue;
565
566    let registry = global_registry()?;
567    registry
568        .snapshot()
569        .metrics()
570        .iter()
571        .find(|metric| {
572            metric.name == name
573                && metric
574                    .labels
575                    .iter()
576                    .any(|(metric_key, value)| metric_key == key && value == label)
577        })
578        .and_then(|metric| match metric.value {
579            MetricValue::Counter(value) => Some(value),
580            MetricValue::Gauge(_) | MetricValue::Histogram(_) => None,
581        })
582}
583
584impl ServerMetrics {
585    fn register(registry: &MetricsRegistry) -> Option<Self> {
586        Some(Self {
587            connections_active: register_gauge(registry, CONNECTIONS_ACTIVE)?,
588            connection_fates_parked: register_gauge(registry, CONNECTION_FATES_PARKED)?,
589            publishes_total: register_counter(registry, PUBLISHES_TOTAL)?,
590            deliveries_total: register_counter(registry, DELIVERIES_TOTAL)?,
591            transport_deliveries: register_transport_deliveries(registry)?,
592            sheds_total: register_counter(registry, SHEDS_TOTAL)?,
593            self_stream_append_failures_total: register_counter(
594                registry,
595                SELF_STREAM_APPEND_FAILURES_TOTAL,
596            )?,
597            self_streams_disabled_total: register_counter(registry, SELF_STREAMS_DISABLED_TOTAL)?,
598            console_streams_disabled_total: register_counter(
599                registry,
600                CONSOLE_STREAMS_DISABLED_TOTAL,
601            )?,
602            console_failures: register_labelled(
603                registry,
604                CONSOLE_FAILURES_TOTAL,
605                KIND_LABEL,
606                &ConsoleFailure::LABELS,
607            )?,
608            self_streams_reserved_publications_total: register_counter(
609                registry,
610                SELF_STREAMS_RESERVED_PUBLICATIONS_TOTAL,
611            )?,
612            self_streams_unmatched_pressure_total: register_counter(
613                registry,
614                SELF_STREAMS_UNMATCHED_PRESSURE_TOTAL,
615            )?,
616            self_streams_suppressed_pressure_total: register_counter(
617                registry,
618                SELF_STREAMS_SUPPRESSED_PRESSURE_TOTAL,
619            )?,
620            self_streams_pressure_ledger_evictions_total: register_counter(
621                registry,
622                SELF_STREAMS_PRESSURE_LEDGER_EVICTIONS_TOTAL,
623            )?,
624            pressure_refused_total: register_counter(registry, PRESSURE_REFUSED_TOTAL)?,
625            advised_total: register_counter(registry, ADVISED_TOTAL)?,
626            admission_refusals: register_labelled(
627                registry,
628                ADMISSION_REFUSALS_TOTAL,
629                REASON_LABEL,
630                &AdmissionRefusal::LABELS,
631            )?,
632            handshake_refusals: register_labelled(
633                registry,
634                HANDSHAKE_REFUSALS_TOTAL,
635                REASON_LABEL,
636                &UpgradeRefusal::LABELS,
637            )?,
638            receipt_displacements: register_labelled(
639                registry,
640                RECEIPT_DISPLACEMENTS_TOTAL,
641                SCOPE_LABEL,
642                &ReceiptWindowScope::LABELS,
643            )?,
644            receipt_pool_runaway: register_labelled(
645                registry,
646                RECEIPT_POOL_RUNAWAY_TOTAL,
647                POOL_LABEL,
648                &SharedReceiptPool::LABELS,
649            )?,
650        })
651    }
652}
653
654/// One unlabelled counter, registered; `None` after the warning when the
655/// registry refuses it.
656fn register_counter(registry: &MetricsRegistry, name: &'static str) -> Option<CounterHandle> {
657    registry
658        .register_counter(name, no_labels())
659        .inspect_err(warn_registration_failure)
660        .ok()
661}
662
663/// One unlabelled gauge, registered; `None` after the warning when the
664/// registry refuses it.
665fn register_gauge(registry: &MetricsRegistry, name: &'static str) -> Option<GaugeHandle> {
666    registry
667        .register_gauge(name, no_labels())
668        .inspect_err(warn_registration_failure)
669        .ok()
670}
671
672fn register_transport_deliveries(
673    registry: &MetricsRegistry,
674) -> Option<[CounterHandle; MOUNT_KINDS.len()]> {
675    let mut transport_deliveries = Vec::with_capacity(MOUNT_KINDS.len());
676    for transport in MOUNT_KINDS {
677        transport_deliveries.push(
678            registry
679                .register_counter(
680                    TRANSPORT_DELIVERIES_TOTAL,
681                    [(TRANSPORT_LABEL, transport.as_str())],
682                )
683                .inspect_err(warn_registration_failure)
684                .ok()?,
685        );
686    }
687    transport_deliveries.try_into().ok()
688}
689
690/// Pre-registers one counter per label value, in label order.
691///
692/// Every class is registered at `init`, not lazily on first refusal, so the
693/// exposition carries an explicit zero for classes that have not fired. A
694/// missing line and a zero line mean very different things to an operator: the
695/// first is "this server does not know about that failure mode", the second is
696/// "it has not happened".
697fn register_labelled<const N: usize>(
698    registry: &MetricsRegistry,
699    name: &'static str,
700    key: &'static str,
701    labels: &[&'static str; N],
702) -> Option<[CounterHandle; N]> {
703    let mut handles = Vec::with_capacity(N);
704    for label in labels {
705        handles.push(
706            registry
707                .register_counter(name, [(key, *label)])
708                .inspect_err(warn_registration_failure)
709                .ok()?,
710        );
711    }
712    handles.try_into().ok()
713}
714
715/// Names a failed family registration instead of letting `.ok()?` discard it.
716///
717/// `register` still answers `None` and metrics recording stays disabled; what
718/// changes is that the operator can see why from the log.
719fn warn_registration_failure(error: &MetricRegistrationError) {
720    tracing::warn!(
721        %error,
722        "server metrics family registration failed; metrics recording stays disabled"
723    );
724}
725
726const fn no_labels() -> std::iter::Empty<(&'static str, &'static str)> {
727    std::iter::empty()
728}
729
730/// Returns the process-global registry, installing a fresh one when none exists.
731///
732/// Enabling the gate here (rather than in the library) keeps standalone liminal
733/// users on the disabled fast path; the server is the sole installer.
734fn global_or_install() -> Option<&'static MetricsRegistry> {
735    if let Some(registry) = global_registry() {
736        return Some(registry);
737    }
738    // Best-effort install; if a concurrent caller won the race we still read the
739    // now-installed registry back below.
740    let _ = install_global_registry(MetricsRegistry::new());
741    global_registry()
742}
743
744#[cfg(test)]
745mod tests {
746    use super::{
747        CONNECTIONS_ACTIVE, DELIVERIES_TOTAL, PUBLISHES_TOTAL, ServerMetrics, connection_spawned,
748        deliveries_recorded, init, no_labels, publish_accepted, render_dedup_head_block,
749    };
750    use liminal::durability::DedupHeadCounts;
751    use liminal::metrics::{MetricsRegistry, global_registry, render};
752
753    /// The dedup head block, byte for byte, for one reading with three
754    /// distinct values: each counter's `# HELP`, `# TYPE` and value line, in
755    /// `named()` order, each line ended by one newline.
756    #[test]
757    fn the_dedup_head_block_renders_byte_for_byte() {
758        let mut output = String::new();
759        render_dedup_head_block(
760            &mut output,
761            DedupHeadCounts {
762                refusals: 7,
763                record_lost_races: 0,
764                repairs: 12,
765            },
766        );
767        assert_eq!(
768            output,
769            "# HELP liminal_dedup_head_refusals_total liminal_dedup_head_refusals_total\n\
770             # TYPE liminal_dedup_head_refusals_total counter\n\
771             liminal_dedup_head_refusals_total 7\n\
772             # HELP liminal_dedup_head_record_lost_races_total \
773             liminal_dedup_head_record_lost_races_total\n\
774             # TYPE liminal_dedup_head_record_lost_races_total counter\n\
775             liminal_dedup_head_record_lost_races_total 0\n\
776             # HELP liminal_dedup_head_repairs_total liminal_dedup_head_repairs_total\n\
777             # TYPE liminal_dedup_head_repairs_total counter\n\
778             liminal_dedup_head_repairs_total 12\n"
779        );
780    }
781
782    #[test]
783    fn init_registers_the_three_server_families_on_the_global_registry()
784    -> Result<(), Box<dyn std::error::Error>> {
785        init();
786        connection_spawned();
787        publish_accepted();
788        deliveries_recorded(2);
789
790        let registry =
791            global_registry().ok_or("init must install and enable the global registry")?;
792        let exposition = render(&registry.snapshot());
793
794        assert!(exposition.contains(CONNECTIONS_ACTIVE));
795        assert!(exposition.contains(PUBLISHES_TOTAL));
796        assert!(exposition.contains(DELIVERIES_TOTAL));
797
798        Ok(())
799    }
800
801    /// A failed family registration must not vanish: `register` returns None
802    /// (unchanged) AND warns with the underlying registration error.
803    #[test]
804    fn register_warns_when_a_family_registration_fails() -> Result<(), Box<dyn std::error::Error>> {
805        let registry = MetricsRegistry::new();
806        // Pre-register the gauge's name as a COUNTER so the gauge registration
807        // fails with an incompatible-kind error.
808        registry.register_counter(CONNECTIONS_ACTIVE, no_labels())?;
809
810        let log = crate::test_log::CapturedLog::default();
811        let metrics = log.capture(|| ServerMetrics::register(&registry));
812
813        assert!(
814            metrics.is_none(),
815            "a failed registration must still yield None"
816        );
817        let output = log.contents();
818        assert!(
819            output.contains("WARN") && output.contains(CONNECTIONS_ACTIVE),
820            "expected a warn carrying the registration error, got: {output}"
821        );
822
823        Ok(())
824    }
825}