Skip to main content

moq_net/
stats.rs

1//! Traffic counter collection for moq-net sessions.
2//!
3//! This module only *collects*: build a [`Registry`], hand each session a
4//! tier-scoped [`Handle`] via [`Registry::tier`], and read the counters back
5//! with [`Registry::snapshot`] (host-level rollup, e.g. a `/metrics` scrape) or
6//! [`Registry::report`] (per-broadcast detail). Publishing the counters as MoQ
7//! broadcasts lives in the `moq-stats` crate, which drains a [`Registry`] on an
8//! interval and writes the JSON stats tracks.
9//!
10//! Traffic is bucketed by an arbitrary [`Tier`] label chosen by business logic
11//! (billing class, region, ...) and, within a tier, by broadcast path and
12//! [`Role`] (publisher = egress, subscriber = ingress). Connected sessions are
13//! tracked separately per (tier, auth root), counting presence regardless of
14//! whether any data flows.
15//!
16//! # Where counting happens
17//!
18//! Counting lives in the model layer, not the wire loops. A session tags its
19//! origin pair with a [`Session`] context ([`crate::Client::with_stats`] /
20//! [`crate::Server::with_stats`], which call `origin::{Consumer, Producer}`
21//! `with_stats`); every derived handle (broadcast, announce, track, group, frame)
22//! then attributes its reads (egress = publisher) and writes (ingress =
23//! subscriber) through that context. So any protocol that drives the model gets
24//! the full counter set for free, and an untagged handle pays nothing.
25//!
26//! Per-counter semantics:
27//!
28//! * `announces_started` / `announces_ended`: cumulative broadcast
29//!   announce/unannounce events on this `(tier, role)`. Driven by the tagged
30//!   announce stream on the egress side, and by `create_broadcast` route
31//!   transitions on the ingress side.
32//! * `announced_bytes`: cumulative broadcast-name length summed over each
33//!   model-visible announce and unannounce of this broadcast (the name, not the
34//!   encoded message size, so hop/framing overhead isn't charged, and the count
35//!   is the same across protocol versions). Kept separate from the `bytes`
36//!   payload counter.
37//! * `broadcasts_started` / `broadcasts_ended`: per-(broadcast, context) egress
38//!   subscription sentinel. The first active subscription a context opens for a
39//!   broadcast bumps `broadcasts_started`; the last it closes bumps
40//!   `broadcasts_ended`. Summed across contexts, `broadcasts_started -
41//!   broadcasts_ended` is the number of distinct sessions currently subscribed
42//!   (viewers on the egress side).
43//! * `subscriptions_started` / `subscriptions_ended`: cumulative track-level
44//!   subscriptions opened/dropped (egress `track::Subscriber`, ingress
45//!   `track::Producer`).
46//! * `fetches`: cumulative one-shot group fetches *requested* by a calling
47//!   context, counted once per coalesced fetch at request time. A fetch that
48//!   resolves to `NotFound` still counts. Separate from `subscriptions_started`
49//!   and the viewer refcount; fetched payload still flows into `bytes` /
50//!   `frames` / `groups`.
51//! * `bytes` / `frames` / `groups`: cumulative payload counters bumped as
52//!   groups/frames are read (egress) or written (ingress) in the model.
53//! * `datagrams`: cumulative single-frame groups carried over unreliable QUIC
54//!   datagrams. A datagram is metered as the group it stands in for, so it also
55//!   bumps `groups`, `frames`, and `bytes`; this counter breaks out how many of
56//!   those took the datagram path.
57//! * `sessions_started` / `sessions_ended` ([`Presence`]): cumulative count of
58//!   sessions connected/disconnected under an auth root on this tier.
59//!   Driven by [`Handle::session`] (the [`Session`] context); a
60//!   [`Session::set_tier`] ends the session on the old tier and starts it on
61//!   the new one.
62//!
63//! Counters are strictly monotonic (only `fetch_add`); a counter going
64//! backwards across reads means the underlying entry was garbage collected
65//! (see [`Registry::report`]) and re-created. Downstream consumers should
66//! treat decreases as a fresh segment, summing across resets when computing
67//! lifetime totals.
68//!
69//! # Disabled stats
70//!
71//! [`Registry::disabled`] builds a no-op registry: all counter bumps are
72//! silently dropped and nothing is ever tracked. [`Registry::default`] /
73//! [`Handle::default`] return one, so call sites can hold a [`Handle`]
74//! unconditionally instead of threading an `Option`.
75//!
76//! # Garbage collection
77//!
78//! [`Registry::report`] refills a caller-owned [`Report`] with the current
79//! per-broadcast detail and prunes
80//! entries no longer referenced by any guard, so a publisher draining the
81//! registry on an interval keeps it bounded. A registry that is never
82//! drained accumulates one entry per broadcast path ever seen; call
83//! [`Registry::report`] periodically if you enable a registry without
84//! attaching a publisher. [`Registry::snapshot`] never prunes.
85//!
86//! # Snapshot atomicity
87//!
88//! Each counter readout loads `*_ended` atomics (with `Acquire`)
89//! before their `*_started` counterparts (with `Relaxed`). The matching
90//! end bumps in the RAII guards' `Drop` impls use `Release`. With this
91//! pairing the readout always satisfies `started >= ended` even on
92//! weakly-ordered architectures (ARM, POWER): the `Acquire` load of
93//! ended synchronizes-with the `Release` bump that produced the
94//! observed value, making every write that happened-before that end
95//! (including the matching start bump on whichever thread opened the
96//! guard) visible to the reading thread. Started / payload counters can
97//! then stay `Relaxed` because the visibility comes for free through
98//! the ended pairing. The cost is a slight upward bias on the started
99//! counts when a bump lands between the two loads, which never produces
100//! a logically impossible (`ended > started`) readout for downstream.
101//!
102//! # Cycles
103//!
104//! A [`Registry`] built with excluded patterns ([`Config::exclude`]) returns
105//! empty handles (whose bumps no-op) for any path they match. The `moq-stats`
106//! publisher excludes its own top-level subtree this way, breaking the feedback
107//! loop where serving a stats broadcast would itself generate more stats
108//! traffic.
109
110use std::{
111	collections::HashMap,
112	fmt,
113	sync::{
114		Arc, Mutex,
115		atomic::{AtomicU64, Ordering},
116	},
117};
118
119use kio::Lock;
120use serde::{Deserialize, Deserializer, Serialize, Serializer};
121
122use crate::{AsPath, PathOwned, Pattern, Patterns};
123
124/// Cumulative atomic counters for a single `(tier, role)` on a broadcast.
125///
126/// Started counters bump when a model handle records activity; their `_ended`
127/// counterparts bump from the [`Scope`] / [`Subscription`] / [`Announce`] RAII
128/// guards on drop. `broadcasts_started` / `broadcasts_ended` are the
129/// per-(broadcast, context) egress subscription sentinel (the first active
130/// subscription a context opens for the broadcast bumps `broadcasts_started`,
131/// the last to close bumps `broadcasts_ended`), so summed across contexts
132/// `broadcasts_started - broadcasts_ended` is the count of distinct sessions
133/// currently subscribed.
134// Kept crate-private: the load/store orderings are load-bearing (see the
135// module-level "Snapshot atomicity" note), so external code only ever sees
136// the derived [`Traffic`] readout.
137#[derive(Default, Debug)]
138pub(crate) struct Counters {
139	announces_started: AtomicU64,
140	announces_ended: AtomicU64,
141	// Cumulative broadcast-name length summed over each announce and unannounce
142	// of this broadcast. Counts the name, not the encoded message size, so it
143	// doesn't penalize the broadcast for hop/framing overhead. Kept separate
144	// from `bytes`, which is media payload.
145	announced_bytes: AtomicU64,
146	subscriptions_started: AtomicU64,
147	subscriptions_ended: AtomicU64,
148	// Cumulative one-shot group fetches requested by a calling context. Counted
149	// once per coalesced fetch, at request time rather than on resolution; does
150	// not touch `subscriptions_started` or the viewer refcount.
151	fetches: AtomicU64,
152	broadcasts_started: AtomicU64,
153	broadcasts_ended: AtomicU64,
154	bytes: AtomicU64,
155	frames: AtomicU64,
156	groups: AtomicU64,
157	// Subset of `groups` carried over an unreliable QUIC datagram.
158	datagrams: AtomicU64,
159	// Content the drift budget gave up on before delivery. Disjoint from the
160	// top-level payload counters, which count only what was handed over.
161	stale: ContentCounters,
162}
163
164/// Atomic backing for one [`Content`] readout.
165#[derive(Default, Debug)]
166struct ContentCounters {
167	bytes: AtomicU64,
168	frames: AtomicU64,
169	groups: AtomicU64,
170	datagrams: AtomicU64,
171}
172
173impl ContentCounters {
174	fn snapshot(&self) -> Content {
175		Content {
176			bytes: self.bytes.load(Ordering::Relaxed),
177			frames: self.frames.load(Ordering::Relaxed),
178			groups: self.groups.load(Ordering::Relaxed),
179			datagrams: self.datagrams.load(Ordering::Relaxed),
180		}
181	}
182
183	fn add(&self, content: Content) {
184		self.bytes.fetch_add(content.bytes, Ordering::Relaxed);
185		self.frames.fetch_add(content.frames, Ordering::Relaxed);
186		self.groups.fetch_add(content.groups, Ordering::Relaxed);
187		self.datagrams.fetch_add(content.datagrams, Ordering::Relaxed);
188	}
189}
190
191impl Counters {
192	/// Read all atomics into a [`Traffic`]. Ended counters are read with
193	/// `Acquire` ordering before their started counterparts so the readout
194	/// always satisfies `started >= ended`; see the module-level "Snapshot
195	/// atomicity" note. Started / payload counters stay `Relaxed`: the
196	/// Acquire on ended synchronizes-with the matching Release on the
197	/// end bump, which transitively makes all earlier writes (including
198	/// the prior start bump) visible to this thread.
199	fn snapshot(&self) -> Traffic {
200		let announces_ended = self.announces_ended.load(Ordering::Acquire);
201		let subscriptions_ended = self.subscriptions_ended.load(Ordering::Acquire);
202		let broadcasts_ended = self.broadcasts_ended.load(Ordering::Acquire);
203		let announces_started = self.announces_started.load(Ordering::Relaxed);
204		let announced_bytes = self.announced_bytes.load(Ordering::Relaxed);
205		let subscriptions_started = self.subscriptions_started.load(Ordering::Relaxed);
206		let fetches = self.fetches.load(Ordering::Relaxed);
207		let broadcasts_started = self.broadcasts_started.load(Ordering::Relaxed);
208		let bytes = self.bytes.load(Ordering::Relaxed);
209		let frames = self.frames.load(Ordering::Relaxed);
210		let groups = self.groups.load(Ordering::Relaxed);
211		let datagrams = self.datagrams.load(Ordering::Relaxed);
212		let stale = self.stale.snapshot();
213		Traffic {
214			announces_started,
215			announces_ended,
216			announced_bytes,
217			broadcasts_started,
218			broadcasts_ended,
219			subscriptions_started,
220			subscriptions_ended,
221			fetches,
222			bytes,
223			frames,
224			groups,
225			datagrams,
226			stale,
227		}
228	}
229}
230
231/// Payload-volume counters for content with the same delivery outcome.
232///
233/// This is the nested shape used by [`Traffic::stale`]. The successfully
234/// delivered equivalents remain as top-level [`Traffic`] fields for wire
235/// compatibility with existing stats consumers.
236#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(default)]
238#[non_exhaustive]
239pub struct Content {
240	/// Cumulative payload bytes.
241	pub bytes: u64,
242	/// Cumulative frames.
243	pub frames: u64,
244	/// Cumulative groups.
245	pub groups: u64,
246	/// Cumulative single-frame groups carried as unreliable datagrams.
247	pub datagrams: u64,
248}
249
250impl Content {
251	/// Fold another readout into this one, counter by counter.
252	pub(crate) fn add(&mut self, other: Self) {
253		self.bytes += other.bytes;
254		self.frames += other.frames;
255		self.groups += other.groups;
256		self.datagrams += other.datagrams;
257	}
258}
259
260/// Per-(tier, root) session gauge. One of these is shared (via `Arc`) by every
261/// [`Session`] guard for the same auth root on the same tier: `sessions_started`
262/// bumps on connect, `sessions_ended` on disconnect.
263#[derive(Default, Debug)]
264struct SessionCounters {
265	sessions_started: AtomicU64,
266	sessions_ended: AtomicU64,
267	control: [[AtomicU64; 2]; Control::ALL.len()],
268}
269
270impl SessionCounters {
271	/// Read the gauge into a [`Presence`]. Ended is loaded with `Acquire`
272	/// before started with `Relaxed`, the same pairing as [`Counters::snapshot`],
273	/// so the readout never shows `ended > started`.
274	fn snapshot(&self) -> Presence {
275		let sessions_ended = self.sessions_ended.load(Ordering::Acquire);
276		let sessions_started = self.sessions_started.load(Ordering::Relaxed);
277		Presence {
278			sessions_started,
279			sessions_ended,
280		}
281	}
282
283	fn control(&self, totals: &mut [[u64; 2]; Control::ALL.len()]) {
284		for (total, counters) in totals.iter_mut().zip(&self.control) {
285			for (total, counter) in total.iter_mut().zip(counters) {
286				*total += counter.load(Ordering::Relaxed);
287			}
288		}
289	}
290}
291
292/// A cumulative traffic counter readout for one slice (a broadcast on a
293/// `(tier, role)`, or any sum of such slices).
294///
295/// Every counter is cumulative, so a rate is `delta / delta_t` and a live
296/// count is `started - ended`. This is also the wire shape of one entry on a
297/// published stats track (the `moq-stats` crate serializes maps of these).
298/// Serialize writes both the canonical `*_started`/`*_ended` names and the
299/// legacy `announced`/`*_closed` spellings so an older consumer still reads a
300/// new relay; deserialize accepts either spelling, with the canonical name
301/// winning when both are present. Unknown fields from a newer publisher are
302/// ignored and missing fields from an older one default to zero.
303#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
304#[non_exhaustive]
305pub struct Traffic {
306	/// Cumulative broadcast announce events on this slice.
307	pub announces_started: u64,
308	/// Cumulative broadcast unannounce events on this slice.
309	pub announces_ended: u64,
310	/// Cumulative announce-control bytes: the broadcast name length summed
311	/// over each announce and unannounce. Distinct from `bytes` (payload).
312	pub announced_bytes: u64,
313	/// Per-(broadcast, session) subscription sentinel opens: the first active
314	/// subscription a session holds on a broadcast.
315	pub broadcasts_started: u64,
316	/// Sentinel closes: the session's last subscription to the broadcast ended.
317	pub broadcasts_ended: u64,
318	/// Cumulative track-level subscriptions opened.
319	pub subscriptions_started: u64,
320	/// Cumulative track-level subscriptions closed.
321	pub subscriptions_ended: u64,
322	/// Cumulative one-shot group fetches requested. Counted once per coalesced fetch
323	/// when the fetch is issued, so one that resolves to `NotFound` still counts.
324	/// Separate from `subscriptions_started` and the viewer refcount. Fetched payload still
325	/// flows into `bytes`/`frames`/`groups`.
326	pub fetches: u64,
327	/// Cumulative payload bytes.
328	pub bytes: u64,
329	/// Cumulative frames delivered.
330	pub frames: u64,
331	/// Cumulative groups delivered.
332	pub groups: u64,
333	/// Cumulative single-frame groups delivered over an unreliable QUIC datagram.
334	/// A subset of `groups`: each one also counts there and its payload in
335	/// `frames` / `bytes`.
336	pub datagrams: u64,
337	/// Content skipped because it aged past a subscriber's
338	/// [`max_age`](crate::track::Subscription::max_age) budget. Disjoint from the top-level payload
339	/// counters: skipped content is never handed over. A steady rate here means
340	/// subscribers are consistently behind the live edge.
341	pub stale: Content,
342}
343
344/// One spelling of a counter edge on the wire: absent, or a present integer.
345///
346/// Decoding goes through `u64`, so an explicit `null` is refused rather than
347/// read as absent; only a missing field takes the default.
348#[derive(Default, Clone, Copy)]
349struct Edge(Option<u64>);
350
351impl<'de> Deserialize<'de> for Edge {
352	fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
353		u64::deserialize(deserializer).map(|v| Self(Some(v)))
354	}
355}
356
357/// Prefer the canonical `*_started`/`*_ended` spelling; fall back to the
358/// legacy name so a consumer built after the rename still reads an older relay.
359fn counter_edge(canonical: Edge, legacy: Edge) -> u64 {
360	canonical.0.or(legacy.0).unwrap_or(0)
361}
362
363#[derive(Serialize)]
364struct TrafficSer {
365	announces_started: u64,
366	announced: u64,
367	announces_ended: u64,
368	announced_closed: u64,
369	announced_bytes: u64,
370	broadcasts_started: u64,
371	broadcasts: u64,
372	broadcasts_ended: u64,
373	broadcasts_closed: u64,
374	subscriptions_started: u64,
375	subscriptions: u64,
376	subscriptions_ended: u64,
377	subscriptions_closed: u64,
378	fetches: u64,
379	bytes: u64,
380	frames: u64,
381	groups: u64,
382	datagrams: u64,
383	stale: Content,
384}
385
386impl From<Traffic> for TrafficSer {
387	fn from(t: Traffic) -> Self {
388		Self {
389			announces_started: t.announces_started,
390			announced: t.announces_started,
391			announces_ended: t.announces_ended,
392			announced_closed: t.announces_ended,
393			announced_bytes: t.announced_bytes,
394			broadcasts_started: t.broadcasts_started,
395			broadcasts: t.broadcasts_started,
396			broadcasts_ended: t.broadcasts_ended,
397			broadcasts_closed: t.broadcasts_ended,
398			subscriptions_started: t.subscriptions_started,
399			subscriptions: t.subscriptions_started,
400			subscriptions_ended: t.subscriptions_ended,
401			subscriptions_closed: t.subscriptions_ended,
402			fetches: t.fetches,
403			bytes: t.bytes,
404			frames: t.frames,
405			groups: t.groups,
406			datagrams: t.datagrams,
407			stale: t.stale,
408		}
409	}
410}
411
412#[derive(Default, Deserialize)]
413#[serde(default)]
414struct TrafficDe {
415	announces_started: Edge,
416	announced: Edge,
417	announces_ended: Edge,
418	announced_closed: Edge,
419	announced_bytes: u64,
420	broadcasts_started: Edge,
421	broadcasts: Edge,
422	broadcasts_ended: Edge,
423	broadcasts_closed: Edge,
424	subscriptions_started: Edge,
425	subscriptions: Edge,
426	subscriptions_ended: Edge,
427	subscriptions_closed: Edge,
428	fetches: u64,
429	bytes: u64,
430	frames: u64,
431	groups: u64,
432	datagrams: u64,
433	stale: Content,
434}
435
436impl From<TrafficDe> for Traffic {
437	fn from(d: TrafficDe) -> Self {
438		Self {
439			announces_started: counter_edge(d.announces_started, d.announced),
440			announces_ended: counter_edge(d.announces_ended, d.announced_closed),
441			announced_bytes: d.announced_bytes,
442			broadcasts_started: counter_edge(d.broadcasts_started, d.broadcasts),
443			broadcasts_ended: counter_edge(d.broadcasts_ended, d.broadcasts_closed),
444			subscriptions_started: counter_edge(d.subscriptions_started, d.subscriptions),
445			subscriptions_ended: counter_edge(d.subscriptions_ended, d.subscriptions_closed),
446			fetches: d.fetches,
447			bytes: d.bytes,
448			frames: d.frames,
449			groups: d.groups,
450			datagrams: d.datagrams,
451			stale: d.stale,
452		}
453	}
454}
455
456impl Serialize for Traffic {
457	fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
458		TrafficSer::from(*self).serialize(serializer)
459	}
460}
461
462impl<'de> Deserialize<'de> for Traffic {
463	fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
464		TrafficDe::deserialize(deserializer).map(Into::into)
465	}
466}
467
468impl Traffic {
469	/// Fold another readout into this one, counter by counter.
470	pub fn add(&mut self, other: Traffic) {
471		self.announces_started += other.announces_started;
472		self.announces_ended += other.announces_ended;
473		self.announced_bytes += other.announced_bytes;
474		self.broadcasts_started += other.broadcasts_started;
475		self.broadcasts_ended += other.broadcasts_ended;
476		self.subscriptions_started += other.subscriptions_started;
477		self.subscriptions_ended += other.subscriptions_ended;
478		self.fetches += other.fetches;
479		self.bytes += other.bytes;
480		self.frames += other.frames;
481		self.groups += other.groups;
482		self.datagrams += other.datagrams;
483		self.stale.add(other.stale);
484	}
485
486	/// True while the broadcast is announced (an announce guard is open).
487	pub fn is_announced(&self) -> bool {
488		self.announces_started > self.announces_ended
489	}
490
491	/// Distinct sessions currently subscribed (viewers on the egress side).
492	pub fn active_broadcasts(&self) -> u64 {
493		self.broadcasts_started.saturating_sub(self.broadcasts_ended)
494	}
495
496	/// Track subscriptions currently open.
497	pub fn active_subscriptions(&self) -> u64 {
498		self.subscriptions_started.saturating_sub(self.subscriptions_ended)
499	}
500
501	/// All bytes attributable to this slice: payload plus announce overhead.
502	/// Both inputs are monotonic, so the sum regresses only when the entry was
503	/// garbage collected and re-created.
504	pub fn total_bytes(&self) -> u64 {
505		self.bytes.saturating_add(self.announced_bytes)
506	}
507
508	/// True once every started counter equals its ended counterpart: no guard is
509	/// held, so no more traffic can flow until a new start.
510	pub fn is_idle(&self) -> bool {
511		self.announces_started == self.announces_ended
512			&& self.subscriptions_started == self.subscriptions_ended
513			&& self.broadcasts_started == self.broadcasts_ended
514	}
515}
516
517/// Connected-session presence for one slice (an auth root on a tier, or any
518/// sum of such slices): cumulative connects and disconnects. `sessions_started
519/// - sessions_ended` is the current live session count.
520///
521/// Like [`Traffic`], this is also the wire shape of one entry on a published
522/// sessions track. Serialize writes both the canonical names and the legacy
523/// `sessions`/`sessions_closed` spellings; deserialize accepts either, with
524/// the canonical name winning when both are present.
525#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
526#[non_exhaustive]
527pub struct Presence {
528	/// Cumulative sessions connected.
529	pub sessions_started: u64,
530	/// Cumulative sessions disconnected.
531	pub sessions_ended: u64,
532}
533
534#[derive(Serialize)]
535struct PresenceSer {
536	sessions_started: u64,
537	sessions: u64,
538	sessions_ended: u64,
539	sessions_closed: u64,
540}
541
542impl From<Presence> for PresenceSer {
543	fn from(p: Presence) -> Self {
544		Self {
545			sessions_started: p.sessions_started,
546			sessions: p.sessions_started,
547			sessions_ended: p.sessions_ended,
548			sessions_closed: p.sessions_ended,
549		}
550	}
551}
552
553#[derive(Default, Deserialize)]
554#[serde(default)]
555struct PresenceDe {
556	sessions_started: Edge,
557	sessions: Edge,
558	sessions_ended: Edge,
559	sessions_closed: Edge,
560}
561
562impl From<PresenceDe> for Presence {
563	fn from(d: PresenceDe) -> Self {
564		Self {
565			sessions_started: counter_edge(d.sessions_started, d.sessions),
566			sessions_ended: counter_edge(d.sessions_ended, d.sessions_closed),
567		}
568	}
569}
570
571impl Serialize for Presence {
572	fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
573		PresenceSer::from(*self).serialize(serializer)
574	}
575}
576
577impl<'de> Deserialize<'de> for Presence {
578	fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
579		PresenceDe::deserialize(deserializer).map(Into::into)
580	}
581}
582
583impl Presence {
584	/// Fold another readout into this one.
585	pub fn add(&mut self, other: Presence) {
586		self.sessions_started += other.sessions_started;
587		self.sessions_ended += other.sessions_ended;
588	}
589
590	/// Sessions currently connected.
591	pub fn active(&self) -> u64 {
592		self.sessions_started.saturating_sub(self.sessions_ended)
593	}
594}
595
596/// Traffic-class label that selects which counter set a session's bumps record
597/// in, so a single [`Registry`] can split customer-facing, cluster-peer, regional,
598/// etc. traffic. Each tracked broadcast keeps a per-tier counter set on both its
599/// publisher and subscriber sides.
600///
601/// The default tier ([`Tier::default`]) is unprefixed: its published tracks are
602/// `publisher.json`, `subscriber.json`, and `sessions.json`. A named tier
603/// prefixes every track with its label, so `Tier::new("region/sjc")` records on
604/// `region/sjc/publisher.json`. The label is an arbitrary path chosen by business
605/// logic; an empty label is the default tier.
606#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
607pub struct Tier(PathOwned);
608
609impl Tier {
610	/// A tier with the given label. An empty label is the default tier.
611	pub fn new(label: impl Into<PathOwned>) -> Self {
612		Self(label.into())
613	}
614
615	/// The tier label, empty for the default tier.
616	pub fn label(&self) -> &PathOwned {
617		&self.0
618	}
619
620	/// True for the default (unprefixed) tier.
621	pub fn is_default(&self) -> bool {
622		self.0.is_empty()
623	}
624
625	/// Track name for this tier: `name` on the default tier, else `<tier>/<name>`.
626	/// This is the naming rule the published stats tracks follow.
627	pub fn track_name(&self, name: &str) -> String {
628		if self.0.is_empty() {
629			name.to_string()
630		} else {
631			format!("{}/{}", self.0.as_str(), name)
632		}
633	}
634
635	/// The tier label as used in metrics: empty (`""`) for the default tier,
636	/// otherwise the label (e.g. `"region/sjc"`). Mirrors the
637	/// wire convention, where the default tier is unprefixed and named
638	/// tiers are `<label>/`-prefixed.
639	pub fn as_str(&self) -> &str {
640		self.0.as_str()
641	}
642}
643
644impl fmt::Display for Tier {
645	/// The label, empty for the default unprefixed tier.
646	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
647		fmt::Display::fmt(&self.0, f)
648	}
649}
650
651/// Publisher (egress) vs subscriber (ingress) side of a broadcast, used as a
652/// label on a [`Snapshot`] traffic row. The internal bump paths track the
653/// side statically, so this only surfaces on the aggregate read side.
654///
655/// This is the direction traffic flowed, not the session role a client advertises
656/// in its SETUP ([`crate::Role`]): one session records on both sides.
657#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
658pub enum Role {
659	/// Egress: bytes this node published to a peer.
660	Publisher,
661	/// Ingress: bytes this node consumed from a peer.
662	Subscriber,
663}
664
665impl Role {
666	fn idx(self) -> usize {
667		match self {
668			Role::Publisher => 0,
669			Role::Subscriber => 1,
670		}
671	}
672
673	/// Lowercase label for this role (`"publisher"` / `"subscriber"`).
674	pub fn as_str(self) -> &'static str {
675		match self {
676			Role::Publisher => "publisher",
677			Role::Subscriber => "subscriber",
678		}
679	}
680}
681
682/// Cumulative bytes sent and received, returned per [`Control`] class by [`Snapshot::control`].
683#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
684#[non_exhaustive]
685pub struct Bytes {
686	/// Bytes successfully encoded for sending, counted even if the stream later fails.
687	pub tx: u64,
688	/// Bytes of complete messages decoded and consumed.
689	pub rx: u64,
690}
691
692/// The bounded class of a successfully encoded or decoded control message.
693///
694/// Counters include prefixes inside a complete codec value, but exclude separately
695/// coded selectors and raw payload writes. A framed IETF body counts after its
696/// framing decodes, before request validation. Shared reply IDs use [`Self::Other`].
697#[derive(Clone, Copy, Debug, PartialEq, Eq)]
698#[non_exhaustive]
699pub enum Control {
700	/// Session setup and information.
701	Session,
702	/// Namespace advertisements and listings.
703	Announce,
704	/// Track metadata and listings.
705	TrackInfo,
706	/// Live subscriptions and their updates.
707	Subscribe,
708	/// Finite fetch requests.
709	Fetch,
710	/// Bandwidth probes.
711	Probe,
712	/// Authorization messages.
713	Auth,
714	/// Session or request migration.
715	Goaway,
716	/// Other protocol control messages, including shared request responses.
717	Other,
718}
719
720impl Control {
721	const ALL: [Self; 9] = [
722		Self::Session,
723		Self::Announce,
724		Self::TrackInfo,
725		Self::Subscribe,
726		Self::Fetch,
727		Self::Probe,
728		Self::Auth,
729		Self::Goaway,
730		Self::Other,
731	];
732
733	/// The stable label used by control metrics.
734	pub fn as_str(self) -> &'static str {
735		match self {
736			Self::Session => "session",
737			Self::Announce => "announce",
738			Self::TrackInfo => "track_info",
739			Self::Subscribe => "subscribe",
740			Self::Fetch => "fetch",
741			Self::Probe => "probe",
742			Self::Auth => "auth",
743			Self::Goaway => "goaway",
744			Self::Other => "other",
745		}
746	}
747}
748
749/// A point-in-time, host-level rollup of a registry's counters, returned
750/// by [`Registry::snapshot`].
751///
752/// Every counter is summed across all broadcasts the registry is tracking and
753/// split by tier and role, plus per-tier connected-session presence. One entry
754/// per tier that recorded any traffic or session, keyed by the tier's label (so
755/// an idle tier is simply absent). Intended for a scrape / `/metrics`-style
756/// endpoint where per-broadcast cardinality is unwanted; use
757/// [`Registry::report`] for the per-broadcast breakdown. A disabled registry
758/// yields no rows.
759#[derive(Debug, Default, Clone, PartialEq, Eq)]
760#[non_exhaustive]
761pub struct Snapshot {
762	/// Traffic totals per tier, indexed by [`Role`] within each tier; read via
763	/// [`Self::traffic`].
764	traffic: HashMap<Tier, [Traffic; 2]>,
765	/// Session presence per tier; read via [`Self::sessions`].
766	sessions: HashMap<Tier, Presence>,
767	control: HashMap<Tier, [[u64; 2]; Control::ALL.len()]>,
768}
769
770impl Snapshot {
771	/// The `(tier, class, totals)` control-message rows with any traffic, sorted by tier and class.
772	/// Includes prefixes inside complete codec values, but excludes separately coded type selectors,
773	/// media payload, and transport framing, acknowledgements, and retransmissions.
774	pub fn control(&self) -> Vec<(Tier, Control, Bytes)> {
775		let mut rows = Vec::new();
776		for (tier, totals) in &self.control {
777			for class in Control::ALL {
778				let [tx, rx] = totals[class as usize];
779				if tx != 0 || rx != 0 {
780					rows.push((tier.clone(), class, Bytes { tx, rx }));
781				}
782			}
783		}
784		rows.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then(a.1.as_str().cmp(b.1.as_str())));
785		rows
786	}
787
788	/// The `(tier, role, totals)` traffic rows, one publisher and one subscriber
789	/// row per tier present. Sorted by tier label then role for stable output.
790	pub fn traffic(&self) -> Vec<(Tier, Role, Traffic)> {
791		let mut rows = Vec::with_capacity(self.traffic.len() * 2);
792		for (tier, roles) in &self.traffic {
793			rows.push((tier.clone(), Role::Publisher, roles[Role::Publisher.idx()]));
794			rows.push((tier.clone(), Role::Subscriber, roles[Role::Subscriber.idx()]));
795		}
796		rows.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then(a.1.idx().cmp(&b.1.idx())));
797		rows
798	}
799
800	/// The `(tier, sessions)` presence rows, one per tier present, sorted by tier
801	/// label.
802	pub fn sessions(&self) -> Vec<(Tier, Presence)> {
803		let mut rows: Vec<_> = self.sessions.iter().map(|(tier, s)| (tier.clone(), *s)).collect();
804		rows.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
805		rows
806	}
807}
808
809/// The per-broadcast detail [`Registry::report`] fills: one traffic entry per
810/// `(broadcast, tier)` and one session entry per `(tier, root)`. Entries are
811/// unordered. Reuse one across drains to keep its capacity.
812#[derive(Debug, Default, Clone)]
813#[non_exhaustive]
814pub struct Report {
815	/// Per-`(broadcast, tier)` traffic, both roles per entry.
816	pub traffic: Vec<TrafficEntry>,
817	/// Per-`(tier, root)` connected-session presence.
818	pub sessions: Vec<SessionEntry>,
819}
820
821/// One `(broadcast, tier)` row of a [`Report`].
822#[derive(Debug, Clone)]
823#[non_exhaustive]
824pub struct TrafficEntry {
825	/// The broadcast path the counters are keyed by.
826	pub path: PathOwned,
827	/// The tier the counters recorded under.
828	pub tier: Tier,
829	/// Egress counters (this node publishing to peers).
830	pub publisher: Traffic,
831	/// Ingress counters (this node consuming from peers).
832	pub subscriber: Traffic,
833}
834
835/// One `(tier, root)` row of a [`Report`].
836#[derive(Debug, Clone)]
837#[non_exhaustive]
838pub struct SessionEntry {
839	/// The tier the sessions recorded under.
840	pub tier: Tier,
841	/// The auth root the sessions connected under.
842	pub root: PathOwned,
843	/// The cumulative connect/disconnect gauge.
844	pub presence: Presence,
845}
846
847/// Settings for a [`Registry`]. Construct with [`Config::new`] and chain the
848/// `with_*` setters, then hand it to [`Registry::new`].
849///
850/// Every field here is about *collection*; the publishing knobs (origin,
851/// interval, node, ...) live on the `moq-stats` producer config.
852#[derive(Clone, Debug, Default)]
853#[non_exhaustive]
854pub struct Config {
855	/// Patterns whose broadcasts are not tracked: a matching path gets an empty
856	/// handle whose bumps no-op. A publisher excludes its own stats subtree
857	/// (`.stats/**`) this way, breaking the stats-of-stats feedback loop. Empty
858	/// (the default) tracks everything.
859	pub exclude: Patterns,
860}
861
862impl Config {
863	/// A config with default settings: nothing excluded.
864	pub fn new() -> Self {
865		Self::default()
866	}
867
868	/// Add a pattern to exclude from tracking. May be chained to exclude
869	/// several.
870	pub fn with_exclude(mut self, pattern: Pattern) -> Self {
871		self.exclude.insert(pattern);
872		self
873	}
874}
875
876/// Counter collection registry. Cheap to clone (`Arc` inside for the shared
877/// state). One instance per relay; sessions get tier-scoped handles via
878/// [`Registry::tier`]. The `moq-stats` crate drains it with
879/// [`Registry::report`] to publish the counters as MoQ broadcasts.
880#[derive(Clone)]
881pub struct Registry {
882	/// Paths these patterns match get empty handles (bumps no-op); see
883	/// [`Config::exclude`].
884	exclude: Patterns,
885	/// `None` for a disabled registry: bumps are dropped and nothing is tracked.
886	shared: Option<Arc<Shared>>,
887}
888
889/// State shared by every clone of a [`Registry`].
890struct Shared {
891	/// Completed entries folded by tier before pruning. Lock before either map
892	/// so a snapshot sees each entry either here or live, never both or neither.
893	retired: Lock<Snapshot>,
894	entries: Lock<HashMap<PathOwned, Arc<BroadcastEntry>>>,
895	/// Connected-session gauges keyed by `(tier, auth root)`. Independent of any
896	/// broadcast; surfaced on the per-tier session tracks. A tier's inner map is
897	/// created the first time a session records under it.
898	sessions: Lock<HashMap<Tier, HashMap<PathOwned, Arc<SessionCounters>>>>,
899}
900
901/// Per-broadcast counters, lazily split by tier. A tier's [`TierCounters`] is
902/// created the first time a guard records under that label, so the set of tiers
903/// is fully dynamic. A [`Scope`] resolves the `Arc<TierCounters>` once per session
904/// tier and hands it to the guards and meters it creates, so the per-byte path
905/// never touches this map.
906struct BroadcastEntry {
907	tiers: Mutex<HashMap<Tier, Arc<TierCounters>>>,
908}
909
910impl BroadcastEntry {
911	fn new() -> Self {
912		Self {
913			tiers: Mutex::new(HashMap::new()),
914		}
915	}
916
917	/// Get-or-create the counters for `tier` on this broadcast.
918	fn tier(&self, tier: &Tier) -> Arc<TierCounters> {
919		self.tiers
920			.lock()
921			.expect("stats tiers poisoned")
922			.entry(tier.clone())
923			.or_default()
924			.clone()
925	}
926}
927
928/// Publisher and subscriber [`Counters`] for one `(broadcast, tier)`. The two
929/// sides are named explicitly (rather than indexed by a `Role` enum) because
930/// the bump-path call sites always know which side they're on at compile time.
931#[derive(Default)]
932struct TierCounters {
933	publisher: Counters,
934	subscriber: Counters,
935}
936
937impl Registry {
938	/// Build an enabled registry from `config`.
939	pub fn new(config: Config) -> Self {
940		let Config { exclude } = config;
941		Self {
942			exclude,
943			shared: Some(Arc::new(Shared {
944				retired: Lock::default(),
945				entries: Lock::default(),
946				sessions: Default::default(),
947			})),
948		}
949	}
950
951	/// Build a no-op registry: every handle is empty and all bumps are dropped.
952	pub fn disabled() -> Self {
953		Self {
954			exclude: Patterns::new(),
955			shared: None,
956		}
957	}
958
959	/// The excluded patterns. See [`Config::exclude`].
960	pub fn exclude(&self) -> &Patterns {
961		&self.exclude
962	}
963
964	/// The shared state, panicking for a disabled registry. Tests build enabled
965	/// registries so this is always present.
966	#[cfg(test)]
967	fn shared(&self) -> &Arc<Shared> {
968		self.shared.as_ref().expect("enabled stats registry")
969	}
970
971	/// Returns a tier-scoped handle. Bumps through this handle land in the
972	/// tier's counters.
973	pub fn tier(&self, tier: Tier) -> Handle {
974		Handle {
975			stats: self.clone(),
976			tier,
977		}
978	}
979
980	fn entry(&self, path: impl AsPath) -> Option<Arc<BroadcastEntry>> {
981		// A disabled registry never allocates state.
982		let shared = self.shared.as_ref()?;
983		let path = path.as_path();
984		// Skip excluded paths (our own stats broadcasts and any sibling category
985		// under the same prefix) so serving a stats broadcast doesn't generate
986		// more stats.
987		if self.exclude.matches(path.as_str()) {
988			return None;
989		}
990		let owned = path.to_owned();
991		let mut entries = shared.entries.lock();
992		Some(
993			entries
994				.entry(owned)
995				.or_insert_with(|| Arc::new(BroadcastEntry::new()))
996				.clone(),
997		)
998	}
999
1000	/// Get-or-create the session gauge for `root` on `tier`. `None` for a
1001	/// disabled registry. Unlike [`Self::entry`], roots are auth scopes (never
1002	/// under a stats prefix), so no cycle-breaking filter is needed.
1003	fn session_counters(&self, tier: &Tier, root: impl AsPath) -> Option<Arc<SessionCounters>> {
1004		let shared = self.shared.as_ref()?;
1005		let owned = root.as_path().to_owned();
1006		let mut sessions = shared.sessions.lock();
1007		Some(
1008			sessions
1009				.entry(tier.clone())
1010				.or_default()
1011				.entry(owned)
1012				.or_default()
1013				.clone(),
1014		)
1015	}
1016
1017	/// Take a host-level [`Snapshot`]: every counter summed across all
1018	/// tracked broadcasts, split by tier and role, plus per-tier session
1019	/// presence. Briefly takes the entry then the session locks. Returns an
1020	/// all-zero snapshot for a disabled registry.
1021	///
1022	/// Unlike [`Registry::report`], this collapses per-broadcast detail into
1023	/// node lifetime totals (what a `/metrics`-style scrape wants) and never prunes.
1024	/// Retired entries remain in these totals after [`Self::report`] prunes them.
1025	pub fn snapshot(&self) -> Snapshot {
1026		let Some(shared) = self.shared.as_ref() else {
1027			return Snapshot::default();
1028		};
1029		let retired = shared.retired.lock();
1030		let mut snap = retired.clone();
1031		{
1032			let entries = shared.entries.lock();
1033			for entry in entries.values() {
1034				let tiers = entry.tiers.lock().expect("stats tiers poisoned");
1035				for (tier, counters) in tiers.iter() {
1036					let totals = snap.traffic.entry(tier.clone()).or_default();
1037					totals[Role::Publisher.idx()].add(counters.publisher.snapshot());
1038					totals[Role::Subscriber.idx()].add(counters.subscriber.snapshot());
1039				}
1040			}
1041		}
1042		{
1043			let sessions = shared.sessions.lock();
1044			for (tier, roots) in sessions.iter() {
1045				let totals = snap.sessions.entry(tier.clone()).or_default();
1046				for counters in roots.values() {
1047					totals.add(counters.snapshot());
1048					counters.control(snap.control.entry(tier.clone()).or_default());
1049				}
1050			}
1051		}
1052		snap
1053	}
1054
1055	/// Refill `report` with the per-broadcast detail and prune dead entries.
1056	///
1057	/// Clears `report`, keeping its capacity so a caller draining on an
1058	/// interval reuses one report instead of allocating per drain, then fills
1059	/// every `(broadcast, tier)` traffic readout and every `(tier, root)`
1060	/// session gauge. Entries no guard references anymore are then dropped
1061	/// (their final values are still in the report, so a publisher draining on
1062	/// an interval emits the closing readout exactly once). A pruned path that
1063	/// sees traffic again restarts from zero; see the module docs on counter
1064	/// resets. Leaves the report empty for a disabled registry.
1065	pub fn report(&self, report: &mut Report) {
1066		report.traffic.clear();
1067		report.sessions.clear();
1068		let Some(shared) = self.shared.as_ref() else {
1069			return;
1070		};
1071		let mut retired = shared.retired.lock();
1072		{
1073			let mut entries = shared.entries.lock();
1074			for (path, entry) in entries.iter() {
1075				let tiers = entry.tiers.lock().expect("stats tiers poisoned");
1076				for (tier, counters) in tiers.iter() {
1077					report.traffic.push(TrafficEntry {
1078						path: path.clone(),
1079						tier: tier.clone(),
1080						publisher: counters.publisher.snapshot(),
1081						subscriber: counters.subscriber.snapshot(),
1082					});
1083				}
1084			}
1085			// Prune entries no guard holds anymore: with only the map's Arc
1086			// left, no future bump can land, so the entry is done. (A guard
1087			// created after the readout above still holds the Arc and keeps
1088			// its entry alive.)
1089			entries.retain(|_, entry| {
1090				if Arc::strong_count(entry) > 1 {
1091					return true;
1092				}
1093				let mut tiers = entry.tiers.lock().expect("stats tiers poisoned");
1094				tiers.retain(|tier, counters| {
1095					if Arc::strong_count(counters) > 1 {
1096						return true;
1097					}
1098					let totals = retired.traffic.entry(tier.clone()).or_default();
1099					totals[Role::Publisher.idx()].add(counters.publisher.snapshot());
1100					totals[Role::Subscriber.idx()].add(counters.subscriber.snapshot());
1101					false
1102				});
1103				!tiers.is_empty()
1104			});
1105		}
1106		{
1107			let mut sessions = shared.sessions.lock();
1108			for (tier, roots) in sessions.iter() {
1109				for (root, counters) in roots.iter() {
1110					report.sessions.push(SessionEntry {
1111						tier: tier.clone(),
1112						root: root.clone(),
1113						presence: counters.snapshot(),
1114					});
1115				}
1116			}
1117			for (tier, roots) in sessions.iter_mut() {
1118				roots.retain(|_, counters| {
1119					if Arc::strong_count(counters) > 1 {
1120						return true;
1121					}
1122					retired
1123						.sessions
1124						.entry(tier.clone())
1125						.or_default()
1126						.add(counters.snapshot());
1127					counters.control(retired.control.entry(tier.clone()).or_default());
1128					false
1129				});
1130			}
1131			sessions.retain(|_, roots| !roots.is_empty());
1132		}
1133	}
1134}
1135
1136impl Default for Registry {
1137	/// A disabled (no-op) registry; see [`Registry::disabled`].
1138	fn default() -> Self {
1139		Self::disabled()
1140	}
1141}
1142
1143/// Tier-scoped wrapper around [`Registry`]. What [`crate::Client::with_stats`] and
1144/// [`crate::Server::with_stats`] accept. Cheap to clone.
1145#[derive(Clone)]
1146pub struct Handle {
1147	stats: Registry,
1148	tier: Tier,
1149}
1150
1151impl Handle {
1152	/// The registry this handle is tied to.
1153	pub fn parent(&self) -> &Registry {
1154		&self.stats
1155	}
1156
1157	/// The tier this handle bumps into.
1158	pub fn tier(&self) -> &Tier {
1159		&self.tier
1160	}
1161
1162	/// Record a connected session authenticated under `root` on this tier. Hold
1163	/// the returned guard for the session's lifetime; dropping it bumps
1164	/// `sessions_ended`. Counts presence regardless of any data flow, so a
1165	/// session that merely connects is still billable. Surfaced on the session
1166	/// track for this tier, keyed by `root`.
1167	pub fn session(&self, root: impl AsPath) -> Session {
1168		Session::new(self.stats.clone(), self.tier.clone(), root)
1169	}
1170}
1171
1172impl Default for Handle {
1173	/// A no-op handle backed by a disabled [`Registry`].
1174	fn default() -> Self {
1175		Registry::disabled().tier(Tier::default())
1176	}
1177}
1178
1179/// Which side of a [`TierCounters`] a bump lands on: publisher (egress) or
1180/// subscriber (ingress). `Default` is `Publisher`, chosen only so an empty
1181/// [`Meter`] / [`Scope`] has one; it never records because its counters are `None`.
1182#[derive(Copy, Clone, Default)]
1183enum Side {
1184	#[default]
1185	Publisher,
1186	Subscriber,
1187}
1188
1189impl Side {
1190	fn counters(self, tier: &TierCounters) -> &Counters {
1191		match self {
1192			Side::Publisher => &tier.publisher,
1193			Side::Subscriber => &tier.subscriber,
1194		}
1195	}
1196}
1197
1198/// Per-connection stats context, created via [`Handle::session`].
1199///
1200/// Cheap to clone (an `Arc` inside): one context is shared by both origin handles
1201/// of a session (its publish and subscribe halves) so presence and viewer counts
1202/// are never double-attributed. It carries three things:
1203///
1204/// * the tier + auth root, so any broadcast reached through a tagged origin handle
1205///   resolves the right per-`(path, tier)` counters,
1206/// * the presence gauge: `sessions_started` bumps when the context is created and
1207///   `sessions_ended` when the last clone drops (a more honest close than a
1208///   separately-held guard),
1209/// * the egress viewer refcount map (first/last active subscription per broadcast),
1210///   driving `broadcasts_started` / `broadcasts_ended`.
1211///
1212/// [`Session::set_tier`] moves a live context to another tier: presence and traffic
1213/// recorded afterwards land there, while what was already counted stays put and an
1214/// open subscription or announce closes on the tier it opened on.
1215///
1216/// [`Session::default`] is the no-op context (disabled registry / untagged caller):
1217/// every bump reached through it is silently dropped, so a handle can hold one
1218/// unconditionally instead of threading an `Option`.
1219#[derive(Clone, Default)]
1220pub struct Session {
1221	/// `None` for the no-op context (disabled registry or a `default()` handle).
1222	inner: Option<Arc<SessionInner>>,
1223}
1224
1225/// The shared state behind a [`Session`]. Its `Drop` (on the last clone) records
1226/// the session as closed.
1227struct SessionInner {
1228	registry: Registry,
1229	/// The auth root the presence gauge is keyed by.
1230	root: PathOwned,
1231	/// The current tier and its presence gauge, swapped together by [`Session::set_tier`].
1232	current: Mutex<Current>,
1233	/// Bumped on every tier change, so a [`Scope`] notices without taking a lock.
1234	generation: AtomicU64,
1235	/// Egress viewer refcount, keyed by absolute broadcast path: the first active
1236	/// subscription this context opens for a broadcast bumps `broadcasts_started`, the last
1237	/// to close bumps `broadcasts_ended` on the same counters, even across a tier change.
1238	viewers: Mutex<HashMap<PathOwned, Viewer>>,
1239}
1240
1241/// The tier a [`Session`] records under right now.
1242struct Current {
1243	tier: Tier,
1244	/// The presence gauge for `(tier, root)`, or `None` for a disabled registry.
1245	presence: Option<Arc<SessionCounters>>,
1246}
1247
1248/// One broadcast's viewer refcount within a [`Session`].
1249struct Viewer {
1250	subscriptions: u32,
1251	/// The counters `broadcasts_started` bumped on, where `broadcasts_ended` lands too.
1252	counters: Arc<TierCounters>,
1253}
1254
1255impl Session {
1256	pub(crate) fn control(&self, class: Control, bytes: usize, received: bool) {
1257		let Some(inner) = &self.inner else { return };
1258		let current = inner.current.lock().expect("stats session poisoned");
1259		if let Some(counters) = &current.presence {
1260			counters.control[class as usize][usize::from(received)].fetch_add(bytes as u64, Ordering::Relaxed);
1261		}
1262	}
1263
1264	fn new(registry: Registry, tier: Tier, root: impl AsPath) -> Self {
1265		let root = root.as_path().to_owned();
1266		let presence = registry.session_counters(&tier, &root);
1267		if let Some(presence) = &presence {
1268			presence.sessions_started.fetch_add(1, Ordering::Relaxed);
1269		}
1270		Self {
1271			inner: Some(Arc::new(SessionInner {
1272				registry,
1273				root,
1274				current: Mutex::new(Current { tier, presence }),
1275				generation: AtomicU64::new(0),
1276				viewers: Mutex::new(HashMap::new()),
1277			})),
1278		}
1279	}
1280
1281	/// Record this session's presence and later traffic under `tier` from now on.
1282	pub fn set_tier(&self, tier: Tier) {
1283		let Some(inner) = &self.inner else { return };
1284		let mut current = inner.current.lock().expect("stats session poisoned");
1285		if current.tier == tier {
1286			return;
1287		}
1288		let presence = inner.registry.session_counters(&tier, &inner.root);
1289		if let Some(presence) = &presence {
1290			presence.sessions_started.fetch_add(1, Ordering::Relaxed);
1291		}
1292		if let Some(old) = std::mem::replace(&mut current.presence, presence) {
1293			// Release pairs with the readout's Acquire load of `sessions_ended`.
1294			old.sessions_ended.fetch_add(1, Ordering::Release);
1295		}
1296		current.tier = tier;
1297		inner.generation.fetch_add(1, Ordering::Release);
1298	}
1299
1300	/// Egress (publisher / reads) scope for a broadcast path. The path is the
1301	/// absolute broadcast name.
1302	pub(crate) fn egress(&self, path: impl AsPath) -> Scope {
1303		self.scope(path, Side::Publisher)
1304	}
1305
1306	/// Ingress (subscriber / writes) scope for a broadcast path.
1307	pub(crate) fn ingress(&self, path: impl AsPath) -> Scope {
1308		self.scope(path, Side::Subscriber)
1309	}
1310
1311	fn scope(&self, path: impl AsPath, side: Side) -> Scope {
1312		let Some(inner) = &self.inner else {
1313			return Scope::default();
1314		};
1315		let path = path.as_path().to_owned();
1316		let resolved = inner.resolve(&path);
1317		Scope {
1318			session: self.clone(),
1319			resolved: Some(Box::new(Mutex::new(resolved))),
1320			side,
1321			path,
1322		}
1323	}
1324
1325	/// Register one active egress subscription to `path` recording on `counters`.
1326	/// The first bumps `broadcasts_started` there.
1327	fn viewer_open(&self, path: &PathOwned, counters: &Arc<TierCounters>) {
1328		let Some(inner) = &self.inner else { return };
1329		let mut viewers = inner.viewers.lock().expect("stats viewers poisoned");
1330		let viewer = viewers.entry(path.clone()).or_insert_with(|| {
1331			counters.publisher.broadcasts_started.fetch_add(1, Ordering::Relaxed);
1332			Viewer {
1333				subscriptions: 0,
1334				counters: counters.clone(),
1335			}
1336		});
1337		viewer.subscriptions += 1;
1338	}
1339
1340	/// Release one active egress subscription to `path`. The last bumps
1341	/// `broadcasts_ended` on the counters the first opened on.
1342	fn viewer_close(&self, path: &PathOwned) {
1343		let Some(inner) = &self.inner else { return };
1344		let mut viewers = inner.viewers.lock().expect("stats viewers poisoned");
1345		let Some(viewer) = viewers.get_mut(path) else { return };
1346		viewer.subscriptions -= 1;
1347		if viewer.subscriptions == 0
1348			&& let Some(viewer) = viewers.remove(path)
1349		{
1350			// Release pairs with the readout's Acquire load of `broadcasts_ended`.
1351			viewer
1352				.counters
1353				.publisher
1354				.broadcasts_ended
1355				.fetch_add(1, Ordering::Release);
1356		}
1357	}
1358}
1359
1360impl SessionInner {
1361	/// The counters `path` records on under the current tier, tagged with the
1362	/// generation they were resolved at.
1363	fn resolve(&self, path: &PathOwned) -> Resolved {
1364		let (generation, tier) = {
1365			let current = self.current.lock().expect("stats session poisoned");
1366			(self.generation.load(Ordering::Relaxed), current.tier.clone())
1367		};
1368		Resolved {
1369			generation,
1370			counters: self.registry.entry(path).map(|entry| entry.tier(&tier)),
1371		}
1372	}
1373}
1374
1375impl Drop for SessionInner {
1376	fn drop(&mut self) {
1377		let current = self.current.get_mut().expect("stats session poisoned");
1378		if let Some(presence) = &current.presence {
1379			// Release pairs with the readout's Acquire load of `sessions_ended`
1380			// (see the module-level "Snapshot atomicity" note).
1381			presence.sessions_ended.fetch_add(1, Ordering::Release);
1382		}
1383	}
1384}
1385
1386// ---------------------------------------------------------------------------
1387// Model-layer carriers
1388//
1389// These are what a tagged `origin::{Consumer, Producer}` threads down through the
1390// derived handles (broadcast -> track -> group -> frame). A tagged origin creates a
1391// [`Scope`] per broadcast, which resolves the per-`(path, tier)` counters again
1392// only after [`Session::set_tier`]; child handles carry a cheap [`Meter`] for the
1393// payload bumps, fixed to the tier the group started under. All of them are no-ops when empty (a
1394// disabled registry, an excluded path, or an untagged caller), so an untagged
1395// handle pays nothing.
1396// ---------------------------------------------------------------------------
1397
1398/// Payload bump handle carried by the group and frame model handles. Cheap to
1399/// clone (an `Option<Arc>` plus a `Side`); empty when the broadcast is untracked.
1400#[derive(Clone, Default)]
1401pub(crate) struct Meter {
1402	counters: Option<Arc<TierCounters>>,
1403	side: Side,
1404}
1405
1406impl Meter {
1407	fn counters(&self) -> Option<&Counters> {
1408		self.counters.as_ref().map(|c| self.side.counters(c))
1409	}
1410
1411	/// Bump `groups` once (a group delivered/consumed on this side).
1412	pub(crate) fn group(&self) {
1413		if let Some(counters) = self.counters() {
1414			counters.groups.fetch_add(1, Ordering::Relaxed);
1415		}
1416	}
1417
1418	/// Bump `frames` by `n`.
1419	pub(crate) fn frames(&self, n: u64) {
1420		if n == 0 {
1421			return;
1422		}
1423		if let Some(counters) = self.counters() {
1424			counters.frames.fetch_add(n, Ordering::Relaxed);
1425		}
1426	}
1427
1428	/// Record one datagram of `n` payload bytes. A datagram stands in for the
1429	/// single-frame group it replaces, so this bumps `groups`, `frames`, and
1430	/// `bytes` alongside `datagrams`.
1431	pub(crate) fn datagram(&self, n: u64) {
1432		if let Some(counters) = self.counters() {
1433			counters.datagrams.fetch_add(1, Ordering::Relaxed);
1434			counters.groups.fetch_add(1, Ordering::Relaxed);
1435			counters.frames.fetch_add(1, Ordering::Relaxed);
1436			counters.bytes.fetch_add(n, Ordering::Relaxed);
1437		}
1438	}
1439
1440	/// Whether this meter attributes anything, i.e. the broadcast is tracked and the
1441	/// handle was tagged. A caller holding a count that has to land exactly once can
1442	/// keep it rather than drop it into an untagged meter.
1443	pub(crate) fn is_tracked(&self) -> bool {
1444		self.counters.is_some()
1445	}
1446
1447	/// Record content skipped before delivery by the drift budget.
1448	pub(crate) fn stale(&self, content: Content) {
1449		if let Some(counters) = self.counters() {
1450			counters.stale.add(content);
1451		}
1452	}
1453
1454	/// Bump `bytes` by `n`.
1455	pub(crate) fn bytes(&self, n: u64) {
1456		if n == 0 {
1457			return;
1458		}
1459		if let Some(counters) = self.counters() {
1460			counters.bytes.fetch_add(n, Ordering::Relaxed);
1461		}
1462	}
1463}
1464
1465/// A per-`(broadcast, side)` scope, carried by the broadcast and track model
1466/// handles. Created by a tagged origin at the broadcast handoff; hands out
1467/// [`Meter`]s for the payload path and RAII guards for the subscription / announce
1468/// lifecycle, each recording under the session's tier at the time. Cheap to clone;
1469/// empty (no-op) when the broadcast is untracked.
1470#[derive(Default)]
1471pub(crate) struct Scope {
1472	/// The owning context: its tier, and the egress viewer refcount map.
1473	session: Session,
1474	/// The counters for `(path, tier)`, re-resolved when the session changes tier.
1475	/// Per clone, so tracks sharing a broadcast never contend on the per-group path;
1476	/// boxed to keep every track handle small. `None` for the no-op context.
1477	resolved: Option<Box<Mutex<Resolved>>>,
1478	side: Side,
1479	/// Absolute broadcast path, used to key the viewer refcount and as the
1480	/// `announced_bytes` length.
1481	path: PathOwned,
1482}
1483
1484/// A [`Scope`]'s counters and the session tier generation they belong to.
1485#[derive(Clone)]
1486struct Resolved {
1487	generation: u64,
1488	/// `None` when untracked.
1489	counters: Option<Arc<TierCounters>>,
1490}
1491
1492impl Clone for Scope {
1493	fn clone(&self) -> Self {
1494		Self {
1495			session: self.session.clone(),
1496			resolved: self
1497				.resolved
1498				.as_ref()
1499				.map(|r| Box::new(Mutex::new(r.lock().expect("stats scope poisoned").clone()))),
1500			side: self.side,
1501			path: self.path.clone(),
1502		}
1503	}
1504}
1505
1506impl Scope {
1507	/// The counters for the session's current tier. Skips the registry unless the
1508	/// tier changed since the last call, so the per-group path stays cheap.
1509	fn counters(&self) -> Option<Arc<TierCounters>> {
1510		let inner = self.session.inner.as_ref()?;
1511		let generation = inner.generation.load(Ordering::Acquire);
1512		let mut resolved = self.resolved.as_ref()?.lock().expect("stats scope poisoned");
1513		if resolved.generation != generation {
1514			*resolved = inner.resolve(&self.path);
1515		}
1516		resolved.counters.clone()
1517	}
1518
1519	/// A payload [`Meter`] for a group/frame derived from this scope.
1520	pub(crate) fn meter(&self) -> Meter {
1521		Meter {
1522			counters: self.counters(),
1523			side: self.side,
1524		}
1525	}
1526
1527	/// Open a track-subscription guard: bumps `subscriptions_started` now and
1528	/// `subscriptions_ended` on drop. On the egress (publisher) side it also drives
1529	/// the context's viewer refcount (`broadcasts_started` / `broadcasts_ended`).
1530	pub(crate) fn subscribe(&self) -> Subscription {
1531		let counters = self.counters();
1532		let mut viewer = None;
1533		if let Some(counters) = &counters {
1534			self.side
1535				.counters(counters)
1536				.subscriptions_started
1537				.fetch_add(1, Ordering::Relaxed);
1538			// Viewer refcount is egress-only: `broadcasts_started` counts distinct sessions
1539			// watching a broadcast.
1540			if matches!(self.side, Side::Publisher) {
1541				self.session.viewer_open(&self.path, counters);
1542				viewer = Some((self.session.clone(), self.path.clone()));
1543			}
1544		}
1545		Subscription {
1546			counters,
1547			side: self.side,
1548			viewer,
1549		}
1550	}
1551
1552	/// Bump the `fetches` counter once (a coalesced group fetch served).
1553	pub(crate) fn fetch(&self) {
1554		if let Some(counters) = self.counters() {
1555			self.side.counters(&counters).fetches.fetch_add(1, Ordering::Relaxed);
1556		}
1557	}
1558
1559	/// Open an announce guard: bumps `announces_started` and adds the path length to
1560	/// `announced_bytes` now; on drop bumps `announces_ended` and adds the path
1561	/// length again. Used for egress announce-stream events and ingress
1562	/// route-transition (un)announces.
1563	pub(crate) fn announce(&self) -> Announce {
1564		let len = self.path.as_str().len() as u64;
1565		let counters = self.counters();
1566		if let Some(counters) = &counters {
1567			let counters = self.side.counters(counters);
1568			counters.announces_started.fetch_add(1, Ordering::Relaxed);
1569			counters.announced_bytes.fetch_add(len, Ordering::Relaxed);
1570		}
1571		Announce {
1572			counters,
1573			side: self.side,
1574			len,
1575		}
1576	}
1577}
1578
1579/// RAII guard for a track subscription (either side). See [`Scope::subscribe`].
1580/// [`Subscription::default`] is an empty no-op guard.
1581#[derive(Default)]
1582#[must_use = "drop the guard to record the subscription as closed"]
1583pub(crate) struct Subscription {
1584	counters: Option<Arc<TierCounters>>,
1585	side: Side,
1586	/// `Some((session, path))` on the egress side, to release the viewer refcount.
1587	viewer: Option<(Session, PathOwned)>,
1588}
1589
1590impl Drop for Subscription {
1591	fn drop(&mut self) {
1592		if let Some((session, path)) = &self.viewer {
1593			session.viewer_close(path);
1594		}
1595		if let Some(counters) = &self.counters {
1596			// Release pairs with the readout's Acquire load of `subscriptions_ended`.
1597			self.side
1598				.counters(counters)
1599				.subscriptions_ended
1600				.fetch_add(1, Ordering::Release);
1601		}
1602	}
1603}
1604
1605/// RAII guard for one announce lifetime. See [`Scope::announce`].
1606#[must_use = "drop the guard to record the unannounce"]
1607pub(crate) struct Announce {
1608	counters: Option<Arc<TierCounters>>,
1609	side: Side,
1610	len: u64,
1611}
1612
1613impl Drop for Announce {
1614	fn drop(&mut self) {
1615		if let Some(counters) = &self.counters {
1616			let counters = self.side.counters(counters);
1617			counters.announced_bytes.fetch_add(self.len, Ordering::Relaxed);
1618			// Release pairs with the readout's Acquire load of `announces_ended`.
1619			counters.announces_ended.fetch_add(1, Ordering::Release);
1620		}
1621	}
1622}
1623
1624#[cfg(test)]
1625mod tests {
1626	use std::sync::{Arc, atomic::Ordering::Relaxed};
1627
1628	use super::*;
1629
1630	#[test]
1631	fn default_tier_has_empty_label() {
1632		let tier = Tier::default();
1633		assert_eq!(tier.as_str(), "");
1634		assert_eq!(tier.to_string(), "");
1635		assert_eq!(tier.track_name("publisher.json"), "publisher.json");
1636	}
1637
1638	/// Counters for `(path, tier)`, creating the tier slot if absent.
1639	fn tier_counters(stats: &Registry, path: &str, tier: &Tier) -> Arc<TierCounters> {
1640		stats
1641			.shared()
1642			.entries
1643			.lock()
1644			.get(&PathOwned::from(path.to_string()))
1645			.expect("entry")
1646			.tier(tier)
1647	}
1648
1649	/// The [`Presence`] for `(tier, root)`, or `None` if absent.
1650	fn session_snapshot(stats: &Registry, tier: &Tier, root: &str) -> Option<Presence> {
1651		stats
1652			.shared()
1653			.sessions
1654			.lock()
1655			.get(tier)
1656			.and_then(|roots| roots.get(&PathOwned::from(root.to_string())).map(|c| c.snapshot()))
1657	}
1658
1659	fn test_stats() -> Registry {
1660		Registry::new(Config::new().with_exclude(Pattern::subtree(".stats").unwrap()))
1661	}
1662
1663	#[test]
1664	fn default_and_named_tiers_are_independent() {
1665		let stats = test_stats();
1666		let default = stats.tier(Tier::default()).session("root");
1667		let regional = stats.tier(Tier::new("region/sjc")).session("root");
1668
1669		default.egress("demo/bbb").meter().bytes(100);
1670		regional.ingress("demo/bbb").meter().bytes(7);
1671
1672		let default_counters = tier_counters(&stats, "demo/bbb", &Tier::default());
1673		let regional_counters = tier_counters(&stats, "demo/bbb", &Tier::new("region/sjc"));
1674		assert_eq!(default_counters.publisher.bytes.load(Relaxed), 100);
1675		assert_eq!(default_counters.subscriber.bytes.load(Relaxed), 0);
1676		assert_eq!(regional_counters.publisher.bytes.load(Relaxed), 0);
1677		assert_eq!(regional_counters.subscriber.bytes.load(Relaxed), 7);
1678	}
1679
1680	#[test]
1681	fn snapshot_rolls_up_by_tier_role_and_sessions() {
1682		let stats = test_stats();
1683		let default = stats.tier(Tier::default());
1684		let regional = stats.tier(Tier::new("region/sjc"));
1685
1686		// Two default-tier sessions under one root, one regional; presence sums them.
1687		let s1 = default.session("acme");
1688		let _s2 = default.session("acme");
1689		let s3 = regional.session("peer");
1690
1691		// Default-tier egress across two broadcasts; the snapshot sums them.
1692		{
1693			let m = s1.egress("demo/aaa").meter();
1694			m.bytes(100);
1695			m.frames(1);
1696			m.group();
1697		}
1698		s1.egress("demo/bbb").meter().bytes(50);
1699		// Regional ingress on a different tier/role stays isolated.
1700		s3.ingress("demo/aaa").meter().bytes(7);
1701
1702		let snap = stats.snapshot();
1703
1704		let slot = |tier, role| {
1705			snap.traffic()
1706				.into_iter()
1707				.find(|(t, r, _)| *t == tier && *r == role)
1708				.map(|(_, _, c)| c)
1709				.expect("row present")
1710		};
1711
1712		let default_publisher = slot(Tier::default(), Role::Publisher);
1713		assert_eq!(
1714			default_publisher.bytes, 150,
1715			"default egress bytes sum across broadcasts"
1716		);
1717		assert_eq!(default_publisher.frames, 1);
1718		assert_eq!(default_publisher.groups, 1);
1719
1720		let regional_subscriber = slot(Tier::new("region/sjc"), Role::Subscriber);
1721		assert_eq!(regional_subscriber.bytes, 7, "regional ingress isolated by tier/role");
1722		assert_eq!(slot(Tier::default(), Role::Subscriber).bytes, 0);
1723		assert_eq!(slot(Tier::new("region/sjc"), Role::Publisher).bytes, 0);
1724
1725		let sessions = |tier| {
1726			snap.sessions()
1727				.into_iter()
1728				.find(|(t, _)| *t == tier)
1729				.map(|(_, s)| s)
1730				.expect("tier present")
1731		};
1732		let default_sessions = sessions(Tier::default());
1733		assert_eq!(
1734			default_sessions.sessions_started, 2,
1735			"two default-tier sessions under one root"
1736		);
1737		assert_eq!(default_sessions.sessions_ended, 0, "guards still held");
1738		assert_eq!(sessions(Tier::new("region/sjc")).sessions_started, 1);
1739	}
1740
1741	fn drain(stats: &Registry) -> Report {
1742		let mut report = Report::default();
1743		stats.report(&mut report);
1744		report
1745	}
1746
1747	#[test]
1748	fn report_reuses_capacity() {
1749		// A reused report is cleared, not appended to, and keeps its buffers.
1750		let stats = test_stats();
1751		let ctx = stats.tier(Tier::default()).session("root");
1752		let _scopes: Vec<_> = (0..8).map(|i| ctx.egress(format!("b/{i}").as_str())).collect();
1753
1754		let mut report = Report::default();
1755		stats.report(&mut report);
1756		assert_eq!(report.traffic.len(), 8);
1757		assert_eq!(report.sessions.len(), 1);
1758		let (traffic, sessions) = (report.traffic.as_ptr(), report.sessions.as_ptr());
1759
1760		stats.report(&mut report);
1761		assert_eq!(report.traffic.len(), 8, "refilled, not appended");
1762		assert_eq!(report.sessions.len(), 1);
1763		assert_eq!(report.traffic.as_ptr(), traffic, "traffic buffer reused");
1764		assert_eq!(report.sessions.as_ptr(), sessions, "sessions buffer reused");
1765	}
1766
1767	#[test]
1768	fn report_returns_detail_and_prunes() {
1769		// report() surfaces per-broadcast rows while a guard is held, keeps the
1770		// entry across drains while live, and prunes it on the first drain
1771		// after the last guard drops (returning the final values that once).
1772		let stats = test_stats();
1773		let key = PathOwned::from("foo/bar");
1774		let ctx = stats.tier(Tier::default()).session("root");
1775		let scope = ctx.egress("foo/bar");
1776		let sub = scope.subscribe();
1777		scope.meter().bytes(42);
1778
1779		let report = drain(&stats);
1780		let row = report
1781			.traffic
1782			.iter()
1783			.find(|row| row.path == key)
1784			.expect("live entry present");
1785		assert_eq!(row.publisher.bytes, 42);
1786		assert_eq!(row.publisher.subscriptions_started, 1);
1787		assert!(!row.publisher.is_idle(), "subscription guard still open");
1788		assert!(
1789			stats.shared().entries.lock().contains_key(&key),
1790			"live entry kept across drains"
1791		);
1792
1793		drop(sub);
1794		drop(scope);
1795
1796		// The drain after the last guard drops still returns the final values,
1797		// then prunes the entry.
1798		let report = drain(&stats);
1799		let row = report
1800			.traffic
1801			.iter()
1802			.find(|row| row.path == key)
1803			.expect("closing values still reported once");
1804		assert_eq!(row.publisher.subscriptions_ended, 1);
1805		assert!(row.publisher.is_idle());
1806		assert!(
1807			!stats.shared().entries.lock().contains_key(&key),
1808			"fully-closed entry pruned"
1809		);
1810		assert!(drain(&stats).traffic.is_empty(), "nothing left after the prune");
1811	}
1812
1813	#[test]
1814	fn report_keeps_idle_but_announced_entry() {
1815		// A broadcast with a live announce guard but no traffic must stay in
1816		// the registry indefinitely: announces_started != announces_ended means a
1817		// subscription could still begin at any moment.
1818		let stats = test_stats();
1819		let key = PathOwned::from("foo/bar");
1820		let ctx = stats.tier(Tier::default()).session("root");
1821		let scope = ctx.egress("foo/bar");
1822		let guard = scope.announce();
1823
1824		for _ in 0..3 {
1825			let report = drain(&stats);
1826			assert!(
1827				report.traffic.iter().any(|row| row.path == key),
1828				"announced-but-idle broadcast stays while the guard is held"
1829			);
1830		}
1831
1832		drop(guard);
1833		drop(scope);
1834		let report = drain(&stats);
1835		let row = report.traffic.iter().find(|row| row.path == key).expect("final report");
1836		assert!(row.publisher.is_idle());
1837		assert!(!stats.shared().entries.lock().contains_key(&key));
1838	}
1839
1840	#[test]
1841	fn report_prunes_empty_session_roots() {
1842		// Once the last session under a root disconnects, the root leaves the
1843		// registry on the drain that reports its final gauge.
1844		let stats = test_stats();
1845		let session = stats.tier(Tier::default()).session("acme");
1846
1847		let report = drain(&stats);
1848		let row = report
1849			.sessions
1850			.iter()
1851			.find(|row| row.root.as_str() == "acme")
1852			.expect("root present");
1853		assert_eq!(row.presence.active(), 1);
1854
1855		drop(session);
1856		let report = drain(&stats);
1857		let row = report
1858			.sessions
1859			.iter()
1860			.find(|row| row.root.as_str() == "acme")
1861			.expect("final gauge reported once");
1862		assert_eq!(row.presence.active(), 0);
1863		assert!(drain(&stats).sessions.is_empty(), "root pruned after the last drain");
1864		assert!(session_snapshot(&stats, &Tier::default(), "acme").is_none());
1865	}
1866
1867	#[test]
1868	fn control_body_totals_follow_retags_and_survive_retirement() {
1869		let registry = Registry::new(Default::default());
1870		let first = Tier::new("internal");
1871		let second = Tier::new("edge");
1872		let context = registry.tier(first.clone()).session("");
1873		context.control(Control::Announce, 17, false);
1874		context.set_tier(second.clone());
1875		context.control(Control::TrackInfo, 23, true);
1876		let expected = vec![
1877			(second, Control::TrackInfo, Bytes { tx: 0, rx: 23 }),
1878			(first, Control::Announce, Bytes { tx: 17, rx: 0 }),
1879		];
1880		assert_eq!(registry.snapshot().control(), expected);
1881		drop(context);
1882		registry.report(&mut Report::default());
1883		assert_eq!(registry.snapshot().control(), expected);
1884		assert_eq!(registry.snapshot().control(), expected, "scrapes do not drain totals");
1885	}
1886
1887	#[test]
1888	fn snapshot_preserves_retired_counters() {
1889		let stats = test_stats();
1890		let tier = Tier::default();
1891		let live = stats.tier(tier.clone()).session("live");
1892		let scope = live.egress("live/video");
1893		let _live_sub = scope.subscribe();
1894		for _ in 0..2 {
1895			let session = stats.tier(tier.clone()).session("retired");
1896			let scope = session.egress("retired/video");
1897			let sub = scope.subscribe();
1898			scope.meter().bytes(100);
1899			drop(sub);
1900			drop(scope);
1901			drop(session);
1902			let before = stats.snapshot();
1903			drain(&stats);
1904			assert_eq!(stats.snapshot(), before, "pruning must not reset host counters");
1905		}
1906		let snap = stats.snapshot();
1907		let traffic = snap
1908			.traffic()
1909			.into_iter()
1910			.find(|(_, role, _)| *role == Role::Publisher)
1911			.unwrap()
1912			.2;
1913		assert_eq!(traffic.bytes, 200);
1914		assert_eq!(traffic.subscriptions_started, 3);
1915		assert_eq!(traffic.subscriptions_ended, 2);
1916		let sessions = snap.sessions().into_iter().find(|(label, _)| label == &tier).unwrap().1;
1917		assert_eq!(sessions.sessions_started, 3);
1918		assert_eq!(sessions.sessions_ended, 2);
1919		assert_eq!(stats.shared().entries.lock().len(), 1, "retired paths are still pruned");
1920		assert_eq!(
1921			stats.shared().sessions.lock()[&tier].len(),
1922			1,
1923			"retired roots are still pruned"
1924		);
1925		let retired = stats.shared().retired.lock();
1926		assert_eq!(retired.traffic.len(), 1, "retain only a total per tier");
1927		assert_eq!(retired.sessions.len(), 1);
1928	}
1929
1930	#[cfg(not(target_family = "wasm"))]
1931	#[test]
1932	fn snapshot_and_report_transfer_counters_once() {
1933		let stats = test_stats();
1934		let worker_stats = stats.clone();
1935		let worker = std::thread::spawn(move || {
1936			for i in 0..256 {
1937				let path = format!("root/{i}");
1938				let session = worker_stats.tier(Tier::default()).session(path.as_str());
1939				session.ingress(path.as_str()).meter().bytes(1);
1940				drop(session);
1941				drain(&worker_stats);
1942			}
1943		});
1944		let mut previous = 0;
1945		while !worker.is_finished() {
1946			let bytes: u64 = stats
1947				.snapshot()
1948				.traffic()
1949				.iter()
1950				.map(|(_, _, traffic)| traffic.bytes)
1951				.sum();
1952			assert!(
1953				bytes >= previous,
1954				"retiring an entry must not double-count or lose its bytes"
1955			);
1956			assert!(bytes <= 256);
1957			previous = bytes;
1958		}
1959		worker.join().unwrap();
1960		let snap = stats.snapshot();
1961		assert_eq!(
1962			snap.traffic().iter().map(|(_, _, traffic)| traffic.bytes).sum::<u64>(),
1963			256
1964		);
1965		assert_eq!(snap.sessions()[0].1.sessions_started, 256);
1966		assert_eq!(snap.sessions()[0].1.sessions_ended, 256);
1967		assert!(stats.shared().entries.lock().is_empty());
1968		assert!(stats.shared().sessions.lock().is_empty());
1969		let retired = stats.shared().retired.lock();
1970		assert_eq!(retired.traffic.len(), 1);
1971		assert_eq!(retired.sessions.len(), 1);
1972	}
1973
1974	#[test]
1975	fn paths_under_exclude_are_no_op() {
1976		// Our own stats broadcasts (and any sibling category under the same
1977		// prefix) must not feed back into the registry.
1978		let stats = test_stats();
1979		let ctx = stats.tier(Tier::default()).session("root");
1980		let scope = ctx.egress(".stats/node/sjc");
1981		scope.meter().bytes(100);
1982		let _guard = scope.announce();
1983		let _sub = scope.subscribe();
1984		assert!(stats.shared().entries.lock().is_empty());
1985	}
1986
1987	#[test]
1988	fn disabled_stats_are_noop() {
1989		// A disabled registry allocates no shared state; every handle is empty
1990		// and bumps are dropped.
1991		let stats = Registry::default();
1992		assert!(stats.shared.is_none());
1993		let ctx = stats.tier(Tier::default()).session("root");
1994		let scope = ctx.egress("demo/bbb");
1995		scope.meter().bytes(100);
1996		let _guard = scope.announce();
1997		let _sub = scope.subscribe();
1998		assert!(drain(&stats).traffic.is_empty());
1999		assert!(stats.snapshot().traffic().is_empty());
2000	}
2001
2002	#[test]
2003	fn session_counts_by_root() {
2004		// session() counts connected sessions per auth root, independent of any
2005		// broadcast: open bumps `sessions_started`, drop bumps `sessions_ended`.
2006		let stats = test_stats();
2007		let ext = stats.tier(Tier::default());
2008
2009		let snap = |root: &str| {
2010			session_snapshot(&stats, &Tier::default(), root).map(|p| (p.sessions_started, p.sessions_ended))
2011		};
2012
2013		let a1 = ext.session("acme");
2014		let a2 = ext.session("acme");
2015		let b1 = ext.session("globex");
2016		assert_eq!(snap("acme"), Some((2, 0)), "two sessions under one root");
2017		assert_eq!(snap("globex"), Some((1, 0)), "a distinct root is counted separately");
2018
2019		drop(a1);
2020		assert_eq!(snap("acme"), Some((2, 1)));
2021		drop(a2);
2022		drop(b1);
2023		assert_eq!(snap("acme"), Some((2, 2)));
2024		assert_eq!(snap("globex"), Some((1, 1)));
2025	}
2026
2027	#[test]
2028	fn traffic_parses_with_missing_and_unknown_fields() {
2029		// Wire forward/backward compat: a frame entry from an older publisher
2030		// (missing fields) or a newer one (extra fields) must still parse.
2031		let old: Traffic = serde_json::from_str(r#"{"announced":1,"bytes":5}"#).expect("older shape parses");
2032		assert_eq!(old.announces_started, 1);
2033		assert_eq!(old.bytes, 5);
2034		assert_eq!(old.announced_bytes, 0, "missing fields default to zero");
2035
2036		let new: Traffic = serde_json::from_str(r#"{"announces_started":1,"announces_ended":1,"future_counter":9}"#)
2037			.expect("newer shape parses");
2038		assert!(new.is_idle());
2039	}
2040
2041	#[test]
2042	fn snapshot_reads_ended_before_started() {
2043		// Reading ended counters before their started counterparts is the
2044		// guarantee that a readout never shows ended > started under concurrent
2045		// bumps. This unit-test pins the ordering at the source level so a
2046		// future refactor that re-orders the loads trips the test.
2047		let src = include_str!("stats.rs");
2048		// Find the body of `impl Counters { fn snapshot(...) ... }` and
2049		// check the line order.
2050		let body_start = src.find("fn snapshot(&self) -> Traffic").expect("snapshot fn present");
2051		let body = &src[body_start..];
2052		let ended_pos = body.find("self.announces_ended.load").expect("announces_ended load");
2053		let started_pos = body
2054			.find("self.announces_started.load")
2055			.expect("announces_started load");
2056		assert!(
2057			ended_pos < started_pos,
2058			"announces_ended must be loaded before announces_started; reversing breaks the started>=ended invariant",
2059		);
2060		let subs_ended_pos = body
2061			.find("self.subscriptions_ended.load")
2062			.expect("subscriptions_ended load");
2063		let subs_pos = body
2064			.find("self.subscriptions_started.load")
2065			.expect("subscriptions_started load");
2066		assert!(
2067			subs_ended_pos < subs_pos,
2068			"subscriptions_ended must be loaded before subscriptions_started",
2069		);
2070		let bcast_ended_pos = body.find("self.broadcasts_ended.load").expect("broadcasts_ended load");
2071		let bcast_pos = body
2072			.find("self.broadcasts_started.load")
2073			.expect("broadcasts_started load");
2074		assert!(
2075			bcast_ended_pos < bcast_pos,
2076			"broadcasts_ended must be loaded before broadcasts_started",
2077		);
2078	}
2079
2080	#[test]
2081	fn context_presence_closes_on_last_clone() {
2082		// The reshaped Session context bumps `sessions_started` once at creation and
2083		// `sessions_ended` only when the last clone drops.
2084		let stats = test_stats();
2085		let snap = |root: &str| {
2086			session_snapshot(&stats, &Tier::default(), root).map(|p| (p.sessions_started, p.sessions_ended))
2087		};
2088
2089		let ctx = stats.tier(Tier::default()).session("acme");
2090		assert_eq!(snap("acme"), Some((1, 0)));
2091
2092		let clone = ctx.clone();
2093		// A clone shares the Arc: no extra `sessions_started`, and dropping one does nothing.
2094		assert_eq!(snap("acme"), Some((1, 0)));
2095		drop(ctx);
2096		assert_eq!(snap("acme"), Some((1, 0)));
2097		drop(clone);
2098		assert_eq!(snap("acme"), Some((1, 1)));
2099	}
2100
2101	#[test]
2102	fn set_tier_moves_presence() {
2103		let stats = test_stats();
2104		let gold = Tier::new("gold");
2105		let snap = |tier: &Tier| session_snapshot(&stats, tier, "acme").map(|p| (p.sessions_started, p.sessions_ended));
2106
2107		let ctx = stats.tier(Tier::default()).session("acme");
2108		ctx.set_tier(Tier::default());
2109		assert_eq!(snap(&Tier::default()), Some((1, 0)), "the same tier is a no-op");
2110
2111		ctx.set_tier(gold.clone());
2112		assert_eq!(snap(&Tier::default()), Some((1, 1)));
2113		assert_eq!(snap(&gold), Some((1, 0)));
2114
2115		drop(ctx);
2116		assert_eq!(snap(&gold), Some((1, 1)), "the session closes on its current tier");
2117	}
2118
2119	#[test]
2120	fn set_tier_moves_subsequent_traffic() {
2121		let stats = test_stats();
2122		let gold = Tier::new("gold");
2123		let ctx = stats.tier(Tier::default()).session("acme");
2124		let scope = ctx.egress("demo/bbb");
2125		let clone = scope.clone();
2126
2127		let before = scope.meter();
2128		let sub = scope.subscribe();
2129		let announce = scope.announce();
2130		before.bytes(10);
2131
2132		ctx.set_tier(gold.clone());
2133		// A meter handed out earlier keeps its tier; everything after moves.
2134		before.bytes(1);
2135		scope.meter().bytes(5);
2136		clone.meter().bytes(7);
2137		let sub2 = scope.subscribe();
2138		scope.fetch();
2139
2140		drop(sub);
2141		drop(sub2);
2142		drop(announce);
2143
2144		let old = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
2145		let new = tier_counters(&stats, "demo/bbb", &gold).publisher.snapshot();
2146		assert_eq!(old.bytes, 11);
2147		assert_eq!(new.bytes, 12);
2148		assert_eq!((old.fetches, new.fetches), (0, 1));
2149
2150		// Each guard closes on the tier it opened on, so neither tier leaks a gauge.
2151		assert_eq!((old.subscriptions_started, old.subscriptions_ended), (1, 1));
2152		assert_eq!((new.subscriptions_started, new.subscriptions_ended), (1, 1));
2153		assert_eq!((old.announces_started, old.announces_ended), (1, 1));
2154		// The viewer opened on the old tier and closes there, even though the
2155		// session's last subscription was opened under the new one.
2156		assert_eq!((old.broadcasts_started, old.broadcasts_ended), (1, 1));
2157		assert_eq!((new.broadcasts_started, new.broadcasts_ended), (0, 0));
2158		assert!(old.is_idle() && new.is_idle());
2159	}
2160
2161	#[test]
2162	fn meter_bumps_the_right_side() {
2163		// A payload meter records on its own side only.
2164		let stats = test_stats();
2165		let ctx = stats.tier(Tier::default()).session("root");
2166
2167		let egress = ctx.egress("demo/bbb").meter();
2168		egress.group();
2169		egress.frames(3);
2170		egress.bytes(100);
2171
2172		let ingress = ctx.ingress("demo/bbb").meter();
2173		ingress.group();
2174		ingress.frames(1);
2175		ingress.bytes(7);
2176
2177		let counters = tier_counters(&stats, "demo/bbb", &Tier::default());
2178		let pub_ = counters.publisher.snapshot();
2179		let sub = counters.subscriber.snapshot();
2180		assert_eq!((pub_.groups, pub_.frames, pub_.bytes), (1, 3, 100));
2181		assert_eq!((sub.groups, sub.frames, sub.bytes), (1, 1, 7));
2182	}
2183
2184	#[test]
2185	fn egress_subscribe_drives_subscriptions_and_viewers() {
2186		// An egress subscription bumps `subscriptions_started` and, being the context's first
2187		// for the broadcast, `broadcasts_started`. Dropping closes both.
2188		let stats = test_stats();
2189		let ctx = stats.tier(Tier::default()).session("root");
2190		let raw = || tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
2191
2192		let scope = ctx.egress("demo/bbb");
2193		let s1 = scope.subscribe();
2194		let s2 = scope.subscribe();
2195		let r = raw();
2196		assert_eq!(r.subscriptions_started, 2, "two track subs");
2197		assert_eq!(r.broadcasts_started, 1, "one context => one viewer");
2198		assert_eq!(r.broadcasts_ended, 0);
2199
2200		drop(s1);
2201		assert_eq!(raw().broadcasts_ended, 0, "context still has a sub open");
2202		drop(s2);
2203		let r = raw();
2204		assert_eq!(r.subscriptions_ended, 2);
2205		assert_eq!(r.broadcasts_ended, 1, "last sub closed => one broadcasts_ended");
2206	}
2207
2208	#[test]
2209	fn distinct_contexts_are_distinct_viewers() {
2210		// Two contexts (sessions) subscribing to the same broadcast are two viewers.
2211		let stats = test_stats();
2212		let raw = || tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
2213
2214		let v1 = stats.tier(Tier::default()).session("a").egress("demo/bbb").subscribe();
2215		assert_eq!(raw().broadcasts_started, 1);
2216		let v2 = stats.tier(Tier::default()).session("b").egress("demo/bbb").subscribe();
2217		assert_eq!(raw().broadcasts_started, 2, "two distinct contexts => two viewers");
2218
2219		drop(v1);
2220		assert_eq!(raw().active_broadcasts(), 1);
2221		drop(v2);
2222		assert_eq!(raw().broadcasts_ended, 2);
2223	}
2224
2225	#[test]
2226	fn ingress_subscription_has_no_viewer() {
2227		// An ingress (producer-lifetime) subscription bumps subscriptions but never
2228		// the viewer refcount, which is egress-only.
2229		let stats = test_stats();
2230		let ctx = stats.tier(Tier::default()).session("root");
2231		let guard = ctx.ingress("demo/bbb").subscribe();
2232		let sub = tier_counters(&stats, "demo/bbb", &Tier::default())
2233			.subscriber
2234			.snapshot();
2235		assert_eq!(sub.subscriptions_started, 1);
2236		assert_eq!(sub.broadcasts_started, 0, "ingress has no viewer refcount");
2237		drop(guard);
2238		assert_eq!(
2239			tier_counters(&stats, "demo/bbb", &Tier::default())
2240				.subscriber
2241				.snapshot()
2242				.subscriptions_ended,
2243			1
2244		);
2245	}
2246
2247	#[test]
2248	fn fetch_counts_separately_from_subscriptions() {
2249		// A fetch bumps `fetches`, not `subscriptions_started` or the viewer refcount.
2250		let stats = test_stats();
2251		let ctx = stats.tier(Tier::default()).session("root");
2252		let scope = ctx.egress("demo/bbb");
2253		scope.fetch();
2254		scope.fetch();
2255		let r = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
2256		assert_eq!(r.fetches, 2);
2257		assert_eq!(r.subscriptions_started, 0);
2258		assert_eq!(r.broadcasts_started, 0);
2259	}
2260
2261	#[test]
2262	fn announce_guard_records_bytes_on_open_and_close() {
2263		// The announce guard bumps `announces_started` + the path length on open, and
2264		// `announces_ended` + the path length again on drop.
2265		let stats = test_stats();
2266		let ctx = stats.tier(Tier::default()).session("root");
2267		let path_len = "demo/bbb".len() as u64;
2268
2269		let guard = ctx.egress("demo/bbb").announce();
2270		let r = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
2271		assert_eq!(r.announces_started, 1);
2272		assert_eq!(r.announces_ended, 0);
2273		assert_eq!(r.announced_bytes, path_len);
2274
2275		drop(guard);
2276		let r = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
2277		assert_eq!(r.announces_ended, 1);
2278		assert_eq!(
2279			r.announced_bytes,
2280			path_len * 2,
2281			"path length recorded on open and close"
2282		);
2283	}
2284
2285	#[test]
2286	fn disabled_context_is_noop() {
2287		// A default (disabled) context resolves empty scopes: every bump is dropped.
2288		let ctx = Session::default();
2289		let scope = ctx.egress("demo/bbb");
2290		scope.meter().bytes(100);
2291		let _guard = scope.announce();
2292		let _sub = scope.subscribe();
2293		scope.fetch();
2294		// No registry to inspect; the point is that none of this panics or allocates.
2295		assert!(ctx.inner.is_none());
2296	}
2297
2298	#[test]
2299	fn fetches_serde_roundtrips() {
2300		// The new `fetches` field is additive: an older frame omits it (defaults to
2301		// zero), and it survives a roundtrip.
2302		let old: Traffic = serde_json::from_str(r#"{"bytes":5}"#).expect("older shape parses");
2303		assert_eq!(old.fetches, 0);
2304
2305		let t = Traffic {
2306			fetches: 9,
2307			..Default::default()
2308		};
2309		let json = serde_json::to_string(&t).unwrap();
2310		let back: Traffic = serde_json::from_str(&json).unwrap();
2311		assert_eq!(back.fetches, 9);
2312	}
2313
2314	#[test]
2315	fn session_snapshot_reads_ended_before_started() {
2316		// Same `ended`-before-`started` invariant as `Counters::snapshot`, pinned
2317		// at the source level so a reordering refactor can't let
2318		// `sessions_ended > sessions_started` leak into a readout.
2319		let src = include_str!("stats.rs");
2320		let body_start = src
2321			.find("fn snapshot(&self) -> Presence")
2322			.expect("SessionCounters::snapshot fn present");
2323		let body = &src[body_start..];
2324		let ended_pos = body.find("self.sessions_ended.load").expect("sessions_ended load");
2325		let started_pos = body.find("self.sessions_started.load").expect("sessions_started load");
2326		assert!(
2327			ended_pos < started_pos,
2328			"sessions_ended must be loaded before sessions_started",
2329		);
2330	}
2331
2332	fn expected_traffic() -> Traffic {
2333		Traffic {
2334			announces_started: 2,
2335			announces_ended: 1,
2336			broadcasts_started: 4,
2337			broadcasts_ended: 3,
2338			subscriptions_started: 6,
2339			subscriptions_ended: 5,
2340			bytes: 9,
2341			..Default::default()
2342		}
2343	}
2344
2345	fn expected_presence() -> Presence {
2346		Presence {
2347			sessions_started: 3,
2348			sessions_ended: 1,
2349		}
2350	}
2351
2352	#[test]
2353	fn traffic_decodes_old_new_and_both_spellings() {
2354		// A new consumer reads an old relay, a new relay, and the dual-name
2355		// frame this serializer actually emits, all as the same Traffic.
2356		let expected = expected_traffic();
2357		let old = r#"{"announced":2,"announced_closed":1,"broadcasts":4,"broadcasts_closed":3,"subscriptions":6,"subscriptions_closed":5,"bytes":9}"#;
2358		let new = r#"{"announces_started":2,"announces_ended":1,"broadcasts_started":4,"broadcasts_ended":3,"subscriptions_started":6,"subscriptions_ended":5,"bytes":9}"#;
2359		assert_eq!(serde_json::from_str::<Traffic>(old).unwrap(), expected);
2360		assert_eq!(serde_json::from_str::<Traffic>(new).unwrap(), expected);
2361		let both = serde_json::to_string(&expected).unwrap();
2362		assert!(both.contains("\"announces_started\":2"), "{both}");
2363		assert!(both.contains("\"announced\":2"), "{both}");
2364		assert!(both.contains("\"announces_ended\":1"), "{both}");
2365		assert!(both.contains("\"announced_closed\":1"), "{both}");
2366		assert!(both.contains("\"broadcasts_started\":4"), "{both}");
2367		assert!(both.contains("\"broadcasts\":4"), "{both}");
2368		assert!(both.contains("\"subscriptions_started\":6"), "{both}");
2369		assert!(both.contains("\"subscriptions\":6"), "{both}");
2370		assert_eq!(serde_json::from_str::<Traffic>(&both).unwrap(), expected);
2371	}
2372
2373	#[test]
2374	fn presence_decodes_old_new_and_both_spellings() {
2375		let expected = expected_presence();
2376		assert_eq!(
2377			serde_json::from_str::<Presence>(r#"{"sessions":3,"sessions_closed":1}"#).unwrap(),
2378			expected
2379		);
2380		assert_eq!(
2381			serde_json::from_str::<Presence>(r#"{"sessions_started":3,"sessions_ended":1}"#).unwrap(),
2382			expected
2383		);
2384		let both = serde_json::to_string(&expected).unwrap();
2385		assert!(both.contains("\"sessions_started\":3"), "{both}");
2386		assert!(both.contains("\"sessions\":3"), "{both}");
2387		assert!(both.contains("\"sessions_ended\":1"), "{both}");
2388		assert!(both.contains("\"sessions_closed\":1"), "{both}");
2389		assert_eq!(serde_json::from_str::<Presence>(&both).unwrap(), expected);
2390	}
2391
2392	#[test]
2393	fn counter_edge_canonical_wins_when_spellings_disagree() {
2394		let traffic: Traffic =
2395			serde_json::from_str(r#"{"announces_started":9,"announced":1,"announces_ended":8,"announced_closed":0}"#)
2396				.unwrap();
2397		assert_eq!(traffic.announces_started, 9);
2398		assert_eq!(traffic.announces_ended, 8);
2399
2400		let presence: Presence =
2401			serde_json::from_str(r#"{"sessions_started":4,"sessions":0,"sessions_ended":2,"sessions_closed":9}"#)
2402				.unwrap();
2403		assert_eq!(presence.sessions_started, 4);
2404		assert_eq!(presence.sessions_ended, 2);
2405	}
2406
2407	#[test]
2408	fn counter_edge_refuses_null() {
2409		// A present null is malformed, not absent: it must not fall through to the legacy spelling or to zero.
2410		assert!(serde_json::from_str::<Traffic>(r#"{"announces_started":null,"announced":7}"#).is_err());
2411		assert!(serde_json::from_str::<Traffic>(r#"{"subscriptions_closed":null}"#).is_err());
2412		assert!(serde_json::from_str::<Presence>(r#"{"sessions_started":null}"#).is_err());
2413		assert!(serde_json::from_str::<Presence>(r#"{"sessions":null,"sessions_closed":1}"#).is_err());
2414	}
2415}