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//! * `announced` / `announced_closed`: cumulative broadcast announce/unannounce
29//!   events on this `(tier, role)`. Driven by the tagged announce stream on the
30//!   egress side, and by `create_broadcast` route transitions on the ingress
31//!   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` / `broadcasts_closed`: per-(broadcast, context) egress
38//!   subscription sentinel. The first active subscription a context opens for a
39//!   broadcast bumps `broadcasts`; the last it closes bumps `broadcasts_closed`.
40//!   Summed across contexts, `broadcasts - broadcasts_closed` is the number of
41//!   distinct sessions currently subscribed (viewers on the egress side).
42//! * `subscriptions` / `subscriptions_closed`: cumulative track-level
43//!   subscriptions opened/dropped (egress `track::Subscriber`, ingress
44//!   `track::Producer`).
45//! * `fetches`: cumulative one-shot group fetches *requested* by a calling
46//!   context, counted once per coalesced fetch at request time. A fetch that
47//!   resolves to `NotFound` still counts. Separate from `subscriptions` and the
48//!   viewer refcount; fetched payload still flows into `bytes` / `frames` /
49//!   `groups`.
50//! * `bytes` / `frames` / `groups`: cumulative payload counters bumped as
51//!   groups/frames are read (egress) or written (ingress) in the model.
52//! * `datagrams`: cumulative single-frame groups carried over unreliable QUIC
53//!   datagrams. A datagram is metered as the group it stands in for, so it also
54//!   bumps `groups`, `frames`, and `bytes`; this counter breaks out how many of
55//!   those took the datagram path.
56//! * `sessions` / `sessions_closed` ([`Presence`]): cumulative count of
57//!   sessions connected/disconnected under an auth root on this tier.
58//!   Driven by [`Handle::session`] (the [`Session`] context).
59//!
60//! Counters are strictly monotonic (only `fetch_add`); a counter going
61//! backwards across reads means the underlying entry was garbage collected
62//! (see [`Registry::report`]) and re-created. Downstream consumers should
63//! treat decreases as a fresh segment, summing across resets when computing
64//! lifetime totals.
65//!
66//! # Disabled stats
67//!
68//! [`Registry::disabled`] builds a no-op registry: all counter bumps are
69//! silently dropped and nothing is ever tracked. [`Registry::default`] /
70//! [`Handle::default`] return one, so call sites can hold a [`Handle`]
71//! unconditionally instead of threading an `Option`.
72//!
73//! # Garbage collection
74//!
75//! [`Registry::report`] returns the current per-broadcast detail and prunes
76//! entries no longer referenced by any guard, so a publisher draining the
77//! registry on an interval keeps it bounded. A registry that is never
78//! drained accumulates one entry per broadcast path ever seen; call
79//! [`Registry::report`] periodically if you enable a registry without
80//! attaching a publisher. [`Registry::snapshot`] never prunes.
81//!
82//! # Snapshot atomicity
83//!
84//! Each counter readout loads `*_closed` atomics (with `Acquire`)
85//! before their open counterparts (with `Relaxed`). The matching close
86//! bumps in the RAII guards' `Drop` impls use `Release`. With this
87//! pairing the readout always satisfies `open >= closed` even on
88//! weakly-ordered architectures (ARM, POWER): the `Acquire` load of
89//! close synchronizes-with the `Release` bump that produced the
90//! observed value, making every write that happened-before that close
91//! (including the matching open bump on whichever thread opened the
92//! guard) visible to the reading thread. Open / payload counters can
93//! then stay `Relaxed` because the visibility comes for free through
94//! the close pairing. The cost is a slight upward bias on the open
95//! counts when a bump lands between the two loads, which never produces
96//! a logically impossible (`closed > open`) readout for downstream.
97//!
98//! # Cycles
99//!
100//! A [`Registry`] built with excluded prefixes ([`Config::exclude`]) returns
101//! empty handles (whose bumps no-op) for any path under one of them. The
102//! `moq-stats` publisher excludes its own top-level prefix this way, breaking
103//! the feedback loop where serving a stats broadcast would itself generate
104//! more stats traffic.
105
106use std::{
107	collections::HashMap,
108	fmt,
109	sync::{
110		Arc, Mutex,
111		atomic::{AtomicU64, Ordering},
112	},
113};
114
115use serde::{Deserialize, Serialize};
116use web_async::Lock;
117
118use crate::{AsPath, PathOwned};
119
120/// Cumulative atomic counters for a single `(tier, role)` on a broadcast.
121///
122/// Open counters bump when a model handle records activity; their `_closed`
123/// counterparts bump from the [`Scope`] / [`Subscription`] / [`Announce`] RAII
124/// guards on drop. `broadcasts` / `broadcasts_closed` are the per-(broadcast,
125/// context) egress subscription sentinel (the first active subscription a context
126/// opens for the broadcast bumps `broadcasts`, the last to close bumps
127/// `broadcasts_closed`), so summed across contexts `broadcasts -
128/// broadcasts_closed` is the count of distinct sessions currently subscribed.
129// Kept crate-private: the load/store orderings are load-bearing (see the
130// module-level "Snapshot atomicity" note), so external code only ever sees
131// the derived [`Traffic`] readout.
132#[derive(Default, Debug)]
133pub(crate) struct Counters {
134	announced: AtomicU64,
135	announced_closed: AtomicU64,
136	// Cumulative broadcast-name length summed over each announce and unannounce
137	// of this broadcast. Counts the name, not the encoded message size, so it
138	// doesn't penalize the broadcast for hop/framing overhead. Kept separate
139	// from `bytes`, which is media payload.
140	announced_bytes: AtomicU64,
141	subscriptions: AtomicU64,
142	subscriptions_closed: AtomicU64,
143	// Cumulative one-shot group fetches requested by a calling context. Counted
144	// once per coalesced fetch, at request time rather than on resolution; does
145	// not touch `subscriptions` or the viewer refcount.
146	fetches: AtomicU64,
147	broadcasts: AtomicU64,
148	broadcasts_closed: AtomicU64,
149	bytes: AtomicU64,
150	frames: AtomicU64,
151	groups: AtomicU64,
152	// Subset of `groups` carried over an unreliable QUIC datagram.
153	datagrams: AtomicU64,
154}
155
156impl Counters {
157	/// Read all atomics into a [`Traffic`]. Closed counters are read with
158	/// `Acquire` ordering before their open counterparts so the readout
159	/// always satisfies `open >= closed`; see the module-level "Snapshot
160	/// atomicity" note. Open / payload counters stay `Relaxed`: the
161	/// Acquire on close synchronizes-with the matching Release on the
162	/// close bump, which transitively makes all earlier writes (including
163	/// the prior open bump) visible to this thread.
164	fn snapshot(&self) -> Traffic {
165		let announced_closed = self.announced_closed.load(Ordering::Acquire);
166		let subscriptions_closed = self.subscriptions_closed.load(Ordering::Acquire);
167		let broadcasts_closed = self.broadcasts_closed.load(Ordering::Acquire);
168		let announced = self.announced.load(Ordering::Relaxed);
169		let announced_bytes = self.announced_bytes.load(Ordering::Relaxed);
170		let subscriptions = self.subscriptions.load(Ordering::Relaxed);
171		let fetches = self.fetches.load(Ordering::Relaxed);
172		let broadcasts = self.broadcasts.load(Ordering::Relaxed);
173		let bytes = self.bytes.load(Ordering::Relaxed);
174		let frames = self.frames.load(Ordering::Relaxed);
175		let groups = self.groups.load(Ordering::Relaxed);
176		let datagrams = self.datagrams.load(Ordering::Relaxed);
177		Traffic {
178			announced,
179			announced_closed,
180			announced_bytes,
181			broadcasts,
182			broadcasts_closed,
183			subscriptions,
184			subscriptions_closed,
185			fetches,
186			bytes,
187			frames,
188			groups,
189			datagrams,
190		}
191	}
192}
193
194/// Per-(tier, root) session gauge. One of these is shared (via `Arc`) by every
195/// [`Session`] guard for the same auth root on the same tier: `sessions`
196/// bumps on connect, `sessions_closed` on disconnect.
197#[derive(Default, Debug)]
198struct SessionCounters {
199	sessions: AtomicU64,
200	sessions_closed: AtomicU64,
201}
202
203impl SessionCounters {
204	/// Read the gauge into a [`Presence`]. Closed is loaded with `Acquire`
205	/// before open with `Relaxed`, the same pairing as [`Counters::snapshot`],
206	/// so the readout never shows `closed > open`.
207	fn snapshot(&self) -> Presence {
208		let sessions_closed = self.sessions_closed.load(Ordering::Acquire);
209		let sessions = self.sessions.load(Ordering::Relaxed);
210		Presence {
211			sessions,
212			sessions_closed,
213		}
214	}
215}
216
217/// A cumulative traffic counter readout for one slice (a broadcast on a
218/// `(tier, role)`, or any sum of such slices).
219///
220/// Every counter is cumulative, so a rate is `delta / delta_t` and a live
221/// count is `open - closed`. This is also the wire shape of one entry on a
222/// published stats track (the `moq-stats` crate serializes maps of these), so
223/// it derives both serde directions; unknown fields from a newer publisher are
224/// ignored and missing fields from an older one default to zero.
225#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(default)]
227#[non_exhaustive]
228pub struct Traffic {
229	/// Cumulative broadcast announce events on this slice.
230	pub announced: u64,
231	/// Cumulative broadcast unannounce events on this slice.
232	pub announced_closed: u64,
233	/// Cumulative announce-control bytes: the broadcast name length summed
234	/// over each announce and unannounce. Distinct from `bytes` (payload).
235	pub announced_bytes: u64,
236	/// Per-(broadcast, session) subscription sentinel opens: the first active
237	/// subscription a session holds on a broadcast.
238	pub broadcasts: u64,
239	/// Sentinel closes: the session's last subscription to the broadcast ended.
240	pub broadcasts_closed: u64,
241	/// Cumulative track-level subscriptions opened.
242	pub subscriptions: u64,
243	/// Cumulative track-level subscriptions closed.
244	pub subscriptions_closed: u64,
245	/// Cumulative one-shot group fetches requested. Counted once per coalesced fetch
246	/// when the fetch is issued, so one that resolves to `NotFound` still counts.
247	/// Separate from `subscriptions` and the viewer refcount. Fetched payload still
248	/// flows into `bytes`/`frames`/`groups`.
249	pub fetches: u64,
250	/// Cumulative payload bytes.
251	pub bytes: u64,
252	/// Cumulative frames delivered.
253	pub frames: u64,
254	/// Cumulative groups delivered.
255	pub groups: u64,
256	/// Cumulative single-frame groups delivered over an unreliable QUIC datagram.
257	/// A subset of `groups`: each one also counts there and its payload in
258	/// `frames` / `bytes`.
259	pub datagrams: u64,
260}
261
262impl Traffic {
263	/// Fold another readout into this one, counter by counter.
264	pub fn add(&mut self, other: Traffic) {
265		self.announced += other.announced;
266		self.announced_closed += other.announced_closed;
267		self.announced_bytes += other.announced_bytes;
268		self.broadcasts += other.broadcasts;
269		self.broadcasts_closed += other.broadcasts_closed;
270		self.subscriptions += other.subscriptions;
271		self.subscriptions_closed += other.subscriptions_closed;
272		self.fetches += other.fetches;
273		self.bytes += other.bytes;
274		self.frames += other.frames;
275		self.groups += other.groups;
276		self.datagrams += other.datagrams;
277	}
278
279	/// True while the broadcast is announced (an announce guard is open).
280	pub fn is_announced(&self) -> bool {
281		self.announced > self.announced_closed
282	}
283
284	/// Distinct sessions currently subscribed (viewers on the egress side).
285	pub fn active_broadcasts(&self) -> u64 {
286		self.broadcasts.saturating_sub(self.broadcasts_closed)
287	}
288
289	/// Track subscriptions currently open.
290	pub fn active_subscriptions(&self) -> u64 {
291		self.subscriptions.saturating_sub(self.subscriptions_closed)
292	}
293
294	/// All bytes attributable to this slice: payload plus announce overhead.
295	/// Both inputs are monotonic, so the sum regresses only when the entry was
296	/// garbage collected and re-created.
297	pub fn total_bytes(&self) -> u64 {
298		self.bytes.saturating_add(self.announced_bytes)
299	}
300
301	/// True once every open counter equals its closed counterpart: no guard is
302	/// held, so no more traffic can flow until a new open.
303	pub fn is_idle(&self) -> bool {
304		self.announced == self.announced_closed
305			&& self.subscriptions == self.subscriptions_closed
306			&& self.broadcasts == self.broadcasts_closed
307	}
308}
309
310/// Connected-session presence for one slice (an auth root on a tier, or any
311/// sum of such slices): cumulative connects and disconnects. `sessions -
312/// sessions_closed` is the current live session count.
313///
314/// Like [`Traffic`], this is also the wire shape of one entry on a published
315/// sessions track, so it derives both serde directions.
316#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(default)]
318#[non_exhaustive]
319pub struct Presence {
320	/// Cumulative sessions connected.
321	pub sessions: u64,
322	/// Cumulative sessions disconnected.
323	pub sessions_closed: u64,
324}
325
326impl Presence {
327	/// Fold another readout into this one.
328	pub fn add(&mut self, other: Presence) {
329		self.sessions += other.sessions;
330		self.sessions_closed += other.sessions_closed;
331	}
332
333	/// Sessions currently connected.
334	pub fn active(&self) -> u64 {
335		self.sessions.saturating_sub(self.sessions_closed)
336	}
337}
338
339/// Traffic-class label that selects which counter set a session's bumps record
340/// in, so a single [`Registry`] can split customer-facing, cluster-peer, regional,
341/// etc. traffic. Each tracked broadcast keeps a per-tier counter set on both its
342/// publisher and subscriber sides.
343///
344/// The default tier ([`Tier::default`]) is unprefixed: its published tracks are
345/// `publisher.json`, `subscriber.json`, and `sessions.json`. A named tier
346/// prefixes every track with its label, so `Tier::new("region/sjc")` records on
347/// `region/sjc/publisher.json`. The label is an arbitrary path chosen by business
348/// logic; an empty label is the default tier.
349#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
350pub struct Tier(PathOwned);
351
352impl Tier {
353	/// A tier with the given label. An empty label is the default tier.
354	pub fn new(label: impl Into<PathOwned>) -> Self {
355		Self(label.into())
356	}
357
358	/// The tier label, empty for the default tier.
359	pub fn label(&self) -> &PathOwned {
360		&self.0
361	}
362
363	/// True for the default (unprefixed) tier.
364	pub fn is_default(&self) -> bool {
365		self.0.is_empty()
366	}
367
368	/// Track name for this tier: `name` on the default tier, else `<tier>/<name>`.
369	/// This is the naming rule the published stats tracks follow.
370	pub fn track_name(&self, name: &str) -> String {
371		if self.0.is_empty() {
372			name.to_string()
373		} else {
374			format!("{}/{}", self.0.as_str(), name)
375		}
376	}
377
378	/// The tier label as used in metrics: empty (`""`) for the default tier,
379	/// otherwise the label (e.g. `"region/sjc"`). Mirrors the
380	/// wire convention, where the default tier is unprefixed and named
381	/// tiers are `<label>/`-prefixed.
382	pub fn as_str(&self) -> &str {
383		self.0.as_str()
384	}
385}
386
387impl fmt::Display for Tier {
388	/// The label, empty for the default unprefixed tier.
389	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390		fmt::Display::fmt(&self.0, f)
391	}
392}
393
394/// Publisher (egress) vs subscriber (ingress) side of a broadcast, used as a
395/// label on a [`Snapshot`] traffic row. The internal bump paths track the
396/// side statically, so this only surfaces on the aggregate read side.
397///
398/// This is the direction traffic flowed, not the session role a client advertises
399/// in its SETUP ([`crate::Role`]): one session records on both sides.
400#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
401pub enum Role {
402	/// Egress: bytes this node published to a peer.
403	Publisher,
404	/// Ingress: bytes this node consumed from a peer.
405	Subscriber,
406}
407
408impl Role {
409	fn idx(self) -> usize {
410		match self {
411			Role::Publisher => 0,
412			Role::Subscriber => 1,
413		}
414	}
415
416	/// Lowercase label for this role (`"publisher"` / `"subscriber"`).
417	pub fn as_str(self) -> &'static str {
418		match self {
419			Role::Publisher => "publisher",
420			Role::Subscriber => "subscriber",
421		}
422	}
423}
424
425/// A point-in-time, host-level rollup of a registry's counters, returned
426/// by [`Registry::snapshot`].
427///
428/// Every counter is summed across all broadcasts the registry is tracking and
429/// split by tier and role, plus per-tier connected-session presence. One entry
430/// per tier that recorded any traffic or session, keyed by the tier's label (so
431/// an idle tier is simply absent). Intended for a scrape / `/metrics`-style
432/// endpoint where per-broadcast cardinality is unwanted; use
433/// [`Registry::report`] for the per-broadcast breakdown. A disabled registry
434/// yields no rows.
435#[derive(Debug, Default, Clone, PartialEq, Eq)]
436#[non_exhaustive]
437pub struct Snapshot {
438	/// Traffic totals per tier, indexed by [`Role`] within each tier; read via
439	/// [`Self::traffic`].
440	traffic: HashMap<Tier, [Traffic; 2]>,
441	/// Session presence per tier; read via [`Self::sessions`].
442	sessions: HashMap<Tier, Presence>,
443}
444
445impl Snapshot {
446	/// The `(tier, role, totals)` traffic rows, one publisher and one subscriber
447	/// row per tier present. Sorted by tier label then role for stable output.
448	pub fn traffic(&self) -> Vec<(Tier, Role, Traffic)> {
449		let mut rows = Vec::with_capacity(self.traffic.len() * 2);
450		for (tier, roles) in &self.traffic {
451			rows.push((tier.clone(), Role::Publisher, roles[Role::Publisher.idx()]));
452			rows.push((tier.clone(), Role::Subscriber, roles[Role::Subscriber.idx()]));
453		}
454		rows.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then(a.1.idx().cmp(&b.1.idx())));
455		rows
456	}
457
458	/// The `(tier, sessions)` presence rows, one per tier present, sorted by tier
459	/// label.
460	pub fn sessions(&self) -> Vec<(Tier, Presence)> {
461		let mut rows: Vec<_> = self.sessions.iter().map(|(tier, s)| (tier.clone(), *s)).collect();
462		rows.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
463		rows
464	}
465}
466
467/// The per-broadcast detail returned by [`Registry::report`]: one traffic
468/// entry per `(broadcast, tier)` and one session entry per `(tier, root)`.
469/// Entries are unordered.
470#[derive(Debug, Default, Clone)]
471#[non_exhaustive]
472pub struct Report {
473	/// Per-`(broadcast, tier)` traffic, both roles per entry.
474	pub traffic: Vec<TrafficEntry>,
475	/// Per-`(tier, root)` connected-session presence.
476	pub sessions: Vec<SessionEntry>,
477}
478
479/// One `(broadcast, tier)` row of a [`Report`].
480#[derive(Debug, Clone)]
481#[non_exhaustive]
482pub struct TrafficEntry {
483	/// The broadcast path the counters are keyed by.
484	pub path: PathOwned,
485	/// The tier the counters recorded under.
486	pub tier: Tier,
487	/// Egress counters (this node publishing to peers).
488	pub publisher: Traffic,
489	/// Ingress counters (this node consuming from peers).
490	pub subscriber: Traffic,
491}
492
493/// One `(tier, root)` row of a [`Report`].
494#[derive(Debug, Clone)]
495#[non_exhaustive]
496pub struct SessionEntry {
497	/// The tier the sessions recorded under.
498	pub tier: Tier,
499	/// The auth root the sessions connected under.
500	pub root: PathOwned,
501	/// The cumulative connect/disconnect gauge.
502	pub presence: Presence,
503}
504
505/// Settings for a [`Registry`]. Construct with [`Config::new`] and chain the
506/// `with_*` setters, then hand it to [`Registry::new`].
507///
508/// Every field here is about *collection*; the publishing knobs (origin,
509/// interval, node, ...) live on the `moq-stats` producer config.
510#[derive(Clone, Debug, Default)]
511#[non_exhaustive]
512pub struct Config {
513	/// Path prefixes whose broadcasts are not tracked: a matching path gets an
514	/// empty handle whose bumps no-op. A publisher excludes its own stats
515	/// prefix this way, breaking the stats-of-stats feedback loop. Empty (the
516	/// default) tracks everything.
517	pub exclude: Vec<PathOwned>,
518}
519
520impl Config {
521	/// A config with default settings: no excluded prefixes.
522	pub fn new() -> Self {
523		Self::default()
524	}
525
526	/// Add a path prefix to exclude from tracking. May be chained to exclude
527	/// several prefixes.
528	pub fn with_exclude(mut self, prefix: impl Into<PathOwned>) -> Self {
529		self.exclude.push(prefix.into());
530		self
531	}
532}
533
534/// Counter collection registry. Cheap to clone (`Arc` inside for the shared
535/// state). One instance per relay; sessions get tier-scoped handles via
536/// [`Registry::tier`]. The `moq-stats` crate drains it with
537/// [`Registry::report`] to publish the counters as MoQ broadcasts.
538#[derive(Clone)]
539pub struct Registry {
540	/// Paths under these prefixes get empty handles (bumps no-op); see
541	/// [`Config::exclude`].
542	exclude: Vec<PathOwned>,
543	/// `None` for a disabled registry: bumps are dropped and nothing is tracked.
544	shared: Option<Arc<Shared>>,
545}
546
547/// State shared by every clone of a [`Registry`].
548struct Shared {
549	entries: Lock<HashMap<PathOwned, Arc<BroadcastEntry>>>,
550	/// Connected-session gauges keyed by `(tier, auth root)`. Independent of any
551	/// broadcast; surfaced on the per-tier session tracks. A tier's inner map is
552	/// created the first time a session records under it.
553	sessions: Lock<HashMap<Tier, HashMap<PathOwned, Arc<SessionCounters>>>>,
554}
555
556/// Per-broadcast counters, lazily split by tier. A tier's [`TierCounters`] is
557/// created the first time a guard records under that label, so the set of tiers
558/// is fully dynamic. Bump-path call sites resolve the `Arc<TierCounters>` once
559/// (at guard creation) and hold it, so the per-byte path never touches this map.
560struct BroadcastEntry {
561	tiers: Mutex<HashMap<Tier, Arc<TierCounters>>>,
562}
563
564impl BroadcastEntry {
565	fn new() -> Self {
566		Self {
567			tiers: Mutex::new(HashMap::new()),
568		}
569	}
570
571	/// Get-or-create the counters for `tier` on this broadcast.
572	fn tier(&self, tier: &Tier) -> Arc<TierCounters> {
573		self.tiers
574			.lock()
575			.expect("stats tiers poisoned")
576			.entry(tier.clone())
577			.or_default()
578			.clone()
579	}
580}
581
582/// Publisher and subscriber [`Counters`] for one `(broadcast, tier)`. The two
583/// sides are named explicitly (rather than indexed by a `Role` enum) because
584/// the bump-path call sites always know which side they're on at compile time.
585#[derive(Default)]
586struct TierCounters {
587	publisher: Counters,
588	subscriber: Counters,
589}
590
591impl Registry {
592	/// Build an enabled registry from `config`.
593	pub fn new(config: Config) -> Self {
594		let Config { exclude } = config;
595		Self {
596			exclude,
597			shared: Some(Arc::new(Shared {
598				entries: Lock::default(),
599				sessions: Default::default(),
600			})),
601		}
602	}
603
604	/// Build a no-op registry: every handle is empty and all bumps are dropped.
605	pub fn disabled() -> Self {
606		Self {
607			exclude: Vec::new(),
608			shared: None,
609		}
610	}
611
612	/// The excluded path prefixes. See [`Config::exclude`].
613	pub fn exclude(&self) -> &[PathOwned] {
614		&self.exclude
615	}
616
617	/// The shared state, panicking for a disabled registry. Tests build enabled
618	/// registries so this is always present.
619	#[cfg(test)]
620	fn shared(&self) -> &Arc<Shared> {
621		self.shared.as_ref().expect("enabled stats registry")
622	}
623
624	/// Returns a tier-scoped handle. Bumps through this handle land in the
625	/// tier's counters.
626	pub fn tier(&self, tier: Tier) -> Handle {
627		Handle {
628			stats: self.clone(),
629			tier,
630		}
631	}
632
633	fn entry(&self, path: impl AsPath) -> Option<Arc<BroadcastEntry>> {
634		// A disabled registry never allocates state.
635		let shared = self.shared.as_ref()?;
636		let path = path.as_path();
637		// Skip excluded prefixes (our own stats broadcasts and any sibling
638		// category under the same prefix) so serving a stats broadcast doesn't
639		// generate more stats.
640		if self.exclude.iter().any(|prefix| path.has_prefix(prefix)) {
641			return None;
642		}
643		let owned = path.to_owned();
644		let mut entries = shared.entries.lock();
645		Some(
646			entries
647				.entry(owned)
648				.or_insert_with(|| Arc::new(BroadcastEntry::new()))
649				.clone(),
650		)
651	}
652
653	/// Get-or-create the session gauge for `root` on `tier`. `None` for a
654	/// disabled registry. Unlike [`Self::entry`], roots are auth scopes (never
655	/// under a stats prefix), so no cycle-breaking filter is needed.
656	fn session_counters(&self, tier: &Tier, root: impl AsPath) -> Option<Arc<SessionCounters>> {
657		let shared = self.shared.as_ref()?;
658		let owned = root.as_path().to_owned();
659		let mut sessions = shared.sessions.lock();
660		Some(
661			sessions
662				.entry(tier.clone())
663				.or_default()
664				.entry(owned)
665				.or_default()
666				.clone(),
667		)
668	}
669
670	/// Take a host-level [`Snapshot`]: every counter summed across all
671	/// tracked broadcasts, split by tier and role, plus per-tier session
672	/// presence. Briefly takes the entry then the session locks. Returns an
673	/// all-zero snapshot for a disabled registry.
674	///
675	/// Unlike [`Registry::report`], this collapses per-broadcast detail into
676	/// node totals (what a `/metrics`-style scrape wants) and never prunes.
677	pub fn snapshot(&self) -> Snapshot {
678		let mut snap = Snapshot::default();
679		let Some(shared) = self.shared.as_ref() else {
680			return snap;
681		};
682		{
683			let entries = shared.entries.lock();
684			for entry in entries.values() {
685				let tiers = entry.tiers.lock().expect("stats tiers poisoned");
686				for (tier, counters) in tiers.iter() {
687					let totals = snap.traffic.entry(tier.clone()).or_default();
688					totals[Role::Publisher.idx()].add(counters.publisher.snapshot());
689					totals[Role::Subscriber.idx()].add(counters.subscriber.snapshot());
690				}
691			}
692		}
693		{
694			let sessions = shared.sessions.lock();
695			for (tier, roots) in sessions.iter() {
696				let totals = snap.sessions.entry(tier.clone()).or_default();
697				for counters in roots.values() {
698					totals.add(counters.snapshot());
699				}
700			}
701		}
702		snap
703	}
704
705	/// Take a per-broadcast [`Report`] and prune dead entries.
706	///
707	/// Returns every `(broadcast, tier)` traffic readout and every `(tier,
708	/// root)` session gauge, then drops the entries no guard references
709	/// anymore (their final values are still in the returned report, so a
710	/// publisher draining on an interval emits the closing readout exactly
711	/// once). A pruned path that sees traffic again restarts from zero; see
712	/// the module docs on counter resets. Returns an empty report for a
713	/// disabled registry.
714	pub fn report(&self) -> Report {
715		let mut report = Report::default();
716		let Some(shared) = self.shared.as_ref() else {
717			return report;
718		};
719		{
720			let mut entries = shared.entries.lock();
721			for (path, entry) in entries.iter() {
722				let tiers = entry.tiers.lock().expect("stats tiers poisoned");
723				for (tier, counters) in tiers.iter() {
724					report.traffic.push(TrafficEntry {
725						path: path.clone(),
726						tier: tier.clone(),
727						publisher: counters.publisher.snapshot(),
728						subscriber: counters.subscriber.snapshot(),
729					});
730				}
731			}
732			// Prune entries no guard holds anymore: with only the map's Arc
733			// left, no future bump can land, so the entry is done. (A guard
734			// created after the readout above still holds the Arc and keeps
735			// its entry alive.)
736			entries.retain(|_, entry| {
737				if Arc::strong_count(entry) > 1 {
738					return true;
739				}
740				let mut tiers = entry.tiers.lock().expect("stats tiers poisoned");
741				tiers.retain(|_, counters| Arc::strong_count(counters) > 1);
742				!tiers.is_empty()
743			});
744		}
745		{
746			let mut sessions = shared.sessions.lock();
747			for (tier, roots) in sessions.iter() {
748				for (root, counters) in roots.iter() {
749					report.sessions.push(SessionEntry {
750						tier: tier.clone(),
751						root: root.clone(),
752						presence: counters.snapshot(),
753					});
754				}
755			}
756			for roots in sessions.values_mut() {
757				roots.retain(|_, counters| Arc::strong_count(counters) > 1);
758			}
759			sessions.retain(|_, roots| !roots.is_empty());
760		}
761		report
762	}
763}
764
765impl Default for Registry {
766	/// A disabled (no-op) registry; see [`Registry::disabled`].
767	fn default() -> Self {
768		Self::disabled()
769	}
770}
771
772/// Tier-scoped wrapper around [`Registry`]. What [`crate::Client::with_stats`] and
773/// [`crate::Server::with_stats`] accept. Cheap to clone.
774#[derive(Clone)]
775pub struct Handle {
776	stats: Registry,
777	tier: Tier,
778}
779
780impl Handle {
781	/// The registry this handle is tied to.
782	pub fn parent(&self) -> &Registry {
783		&self.stats
784	}
785
786	/// The tier this handle bumps into.
787	pub fn tier(&self) -> &Tier {
788		&self.tier
789	}
790
791	/// Record a connected session authenticated under `root` on this tier. Hold
792	/// the returned guard for the session's lifetime; dropping it bumps
793	/// `sessions_closed`. Counts presence regardless of any data flow, so a
794	/// session that merely connects is still billable. Surfaced on the session
795	/// track for this tier, keyed by `root`.
796	pub fn session(&self, root: impl AsPath) -> Session {
797		Session::new(self.clone(), self.stats.session_counters(&self.tier, root))
798	}
799}
800
801impl Default for Handle {
802	/// A no-op handle backed by a disabled [`Registry`].
803	fn default() -> Self {
804		Registry::disabled().tier(Tier::default())
805	}
806}
807
808/// Which side of a [`TierCounters`] a bump lands on: publisher (egress) or
809/// subscriber (ingress). `Default` is `Publisher`, chosen only so an empty
810/// [`Meter`] / [`Scope`] has one; it never records because its counters are `None`.
811#[derive(Copy, Clone, Default)]
812enum Side {
813	#[default]
814	Publisher,
815	Subscriber,
816}
817
818impl Side {
819	fn counters(self, tier: &TierCounters) -> &Counters {
820		match self {
821			Side::Publisher => &tier.publisher,
822			Side::Subscriber => &tier.subscriber,
823		}
824	}
825}
826
827/// Per-connection stats context, created via [`Handle::session`].
828///
829/// Cheap to clone (an `Arc` inside): one context is shared by both origin handles
830/// of a session (its publish and subscribe halves) so presence and viewer counts
831/// are never double-attributed. It carries three things:
832///
833/// * the tier + auth root, so any broadcast reached through a tagged origin handle
834///   resolves the right per-`(path, tier)` counters,
835/// * the presence gauge: `sessions` bumps when the context is created and
836///   `sessions_closed` when the last clone drops (a more honest close than a
837///   separately-held guard),
838/// * the egress viewer refcount map (first/last active subscription per broadcast),
839///   driving `broadcasts` / `broadcasts_closed`.
840///
841/// [`Session::default`] is the no-op context (disabled registry / untagged caller):
842/// every bump reached through it is silently dropped, so a handle can hold one
843/// unconditionally instead of threading an `Option`.
844#[derive(Clone, Default)]
845pub struct Session {
846	/// `None` for the no-op context (disabled registry or a `default()` handle).
847	inner: Option<Arc<SessionInner>>,
848}
849
850/// The shared state behind a [`Session`]. Its `Drop` (on the last clone) records
851/// the session as closed.
852struct SessionInner {
853	/// Registry + tier, so derived model handles resolve `(path, tier)` counters.
854	handle: Handle,
855	/// The presence gauge for `(tier, root)`, or `None` for a disabled registry.
856	presence: Option<Arc<SessionCounters>>,
857	/// Egress viewer refcount, keyed by absolute broadcast path: the first active
858	/// subscription this context opens for a broadcast bumps `broadcasts`, the last
859	/// to close bumps `broadcasts_closed`.
860	viewers: Mutex<HashMap<PathOwned, u32>>,
861}
862
863impl Session {
864	fn new(handle: Handle, presence: Option<Arc<SessionCounters>>) -> Self {
865		if let Some(presence) = &presence {
866			presence.sessions.fetch_add(1, Ordering::Relaxed);
867		}
868		Self {
869			inner: Some(Arc::new(SessionInner {
870				handle,
871				presence,
872				viewers: Mutex::new(HashMap::new()),
873			})),
874		}
875	}
876
877	/// Egress (publisher / reads) scope for a broadcast path. The path is the
878	/// absolute broadcast name; counters are resolved once here.
879	pub(crate) fn egress(&self, path: impl AsPath) -> Scope {
880		self.scope(path, Side::Publisher)
881	}
882
883	/// Ingress (subscriber / writes) scope for a broadcast path.
884	pub(crate) fn ingress(&self, path: impl AsPath) -> Scope {
885		self.scope(path, Side::Subscriber)
886	}
887
888	fn scope(&self, path: impl AsPath, side: Side) -> Scope {
889		let Some(inner) = &self.inner else {
890			return Scope::default();
891		};
892		let path = path.as_path().to_owned();
893		let counters = inner
894			.handle
895			.stats
896			.entry(&path)
897			.map(|entry| entry.tier(&inner.handle.tier));
898		Scope {
899			session: self.clone(),
900			counters,
901			side,
902			path,
903		}
904	}
905
906	/// Register one active egress subscription to `path`, returning `true` if it was
907	/// the first (so the caller bumps `broadcasts`).
908	fn viewer_open(&self, path: &PathOwned) -> bool {
909		let Some(inner) = &self.inner else { return false };
910		let mut viewers = inner.viewers.lock().expect("stats viewers poisoned");
911		let n = viewers.entry(path.clone()).or_insert(0);
912		let first = *n == 0;
913		*n += 1;
914		first
915	}
916
917	/// Release one active egress subscription to `path`, returning `true` if it was
918	/// the last (so the caller bumps `broadcasts_closed`).
919	fn viewer_close(&self, path: &PathOwned) -> bool {
920		let Some(inner) = &self.inner else { return false };
921		let mut viewers = inner.viewers.lock().expect("stats viewers poisoned");
922		match viewers.get_mut(path) {
923			Some(n) => {
924				*n -= 1;
925				if *n == 0 {
926					viewers.remove(path);
927					true
928				} else {
929					false
930				}
931			}
932			None => false,
933		}
934	}
935}
936
937impl Drop for SessionInner {
938	fn drop(&mut self) {
939		if let Some(presence) = &self.presence {
940			// Release pairs with the readout's Acquire load of `sessions_closed`
941			// (see the module-level "Snapshot atomicity" note).
942			presence.sessions_closed.fetch_add(1, Ordering::Release);
943		}
944	}
945}
946
947// ---------------------------------------------------------------------------
948// Model-layer carriers
949//
950// These are what a tagged `origin::{Consumer, Producer}` threads down through the
951// derived handles (broadcast -> track -> group -> frame). A tagged origin resolves
952// the per-`(path, tier)` counters once into a [`Scope`]; child handles carry a
953// cheap [`Meter`] for the payload bumps. All of them are no-ops when empty (a
954// disabled registry, an excluded path, or an untagged caller), so an untagged
955// handle pays nothing.
956// ---------------------------------------------------------------------------
957
958/// Payload bump handle carried by the group and frame model handles. Cheap to
959/// clone (an `Option<Arc>` plus a `Side`); empty when the broadcast is untracked.
960#[derive(Clone, Default)]
961pub(crate) struct Meter {
962	counters: Option<Arc<TierCounters>>,
963	side: Side,
964}
965
966impl Meter {
967	fn counters(&self) -> Option<&Counters> {
968		self.counters.as_ref().map(|c| self.side.counters(c))
969	}
970
971	/// Bump `groups` once (a group delivered/consumed on this side).
972	pub(crate) fn group(&self) {
973		if let Some(counters) = self.counters() {
974			counters.groups.fetch_add(1, Ordering::Relaxed);
975		}
976	}
977
978	/// Bump `frames` by `n`.
979	pub(crate) fn frames(&self, n: u64) {
980		if n == 0 {
981			return;
982		}
983		if let Some(counters) = self.counters() {
984			counters.frames.fetch_add(n, Ordering::Relaxed);
985		}
986	}
987
988	/// Record one datagram of `n` payload bytes. A datagram stands in for the
989	/// single-frame group it replaces, so this bumps `groups`, `frames`, and
990	/// `bytes` alongside `datagrams`.
991	pub(crate) fn datagram(&self, n: u64) {
992		if let Some(counters) = self.counters() {
993			counters.datagrams.fetch_add(1, Ordering::Relaxed);
994			counters.groups.fetch_add(1, Ordering::Relaxed);
995			counters.frames.fetch_add(1, Ordering::Relaxed);
996			counters.bytes.fetch_add(n, Ordering::Relaxed);
997		}
998	}
999
1000	/// Bump `bytes` by `n`.
1001	pub(crate) fn bytes(&self, n: u64) {
1002		if n == 0 {
1003			return;
1004		}
1005		if let Some(counters) = self.counters() {
1006			counters.bytes.fetch_add(n, Ordering::Relaxed);
1007		}
1008	}
1009}
1010
1011/// A per-`(broadcast, tier, side)` scope, carried by the broadcast and track model
1012/// handles. Resolved once by a tagged origin at the broadcast handoff; hands out
1013/// [`Meter`]s for the payload path and RAII guards for the subscription / announce
1014/// lifecycle. Cheap to clone; empty (no-op) when the broadcast is untracked.
1015#[derive(Clone, Default)]
1016pub(crate) struct Scope {
1017	/// The owning context, kept for the egress viewer refcount map.
1018	session: Session,
1019	/// Resolved counters for `(path, tier)`, or `None` when untracked.
1020	counters: Option<Arc<TierCounters>>,
1021	side: Side,
1022	/// Absolute broadcast path, used to key the viewer refcount and as the
1023	/// `announced_bytes` length.
1024	path: PathOwned,
1025}
1026
1027impl Scope {
1028	fn counters(&self) -> Option<&Counters> {
1029		self.counters.as_ref().map(|c| self.side.counters(c))
1030	}
1031
1032	/// A payload [`Meter`] for a group/frame derived from this scope.
1033	pub(crate) fn meter(&self) -> Meter {
1034		Meter {
1035			counters: self.counters.clone(),
1036			side: self.side,
1037		}
1038	}
1039
1040	/// Open a track-subscription guard: bumps `subscriptions` now and
1041	/// `subscriptions_closed` on drop. On the egress (publisher) side it also drives
1042	/// the context's viewer refcount (`broadcasts` / `broadcasts_closed`).
1043	pub(crate) fn subscribe(&self) -> Subscription {
1044		if let Some(counters) = self.counters() {
1045			counters.subscriptions.fetch_add(1, Ordering::Relaxed);
1046		}
1047		// Viewer refcount is egress-only: `broadcasts` counts distinct sessions
1048		// watching a broadcast.
1049		let viewer = if matches!(self.side, Side::Publisher) && self.counters.is_some() {
1050			if self.session.viewer_open(&self.path)
1051				&& let Some(counters) = self.counters()
1052			{
1053				counters.broadcasts.fetch_add(1, Ordering::Relaxed);
1054			}
1055			Some((self.session.clone(), self.path.clone()))
1056		} else {
1057			None
1058		};
1059		Subscription {
1060			counters: self.counters.clone(),
1061			side: self.side,
1062			viewer,
1063		}
1064	}
1065
1066	/// Bump the `fetches` counter once (a coalesced group fetch served).
1067	pub(crate) fn fetch(&self) {
1068		if let Some(counters) = self.counters() {
1069			counters.fetches.fetch_add(1, Ordering::Relaxed);
1070		}
1071	}
1072
1073	/// Open an announce guard: bumps `announced` and adds the path length to
1074	/// `announced_bytes` now; on drop bumps `announced_closed` and adds the path
1075	/// length again. Used for egress announce-stream events and ingress
1076	/// route-transition (un)announces.
1077	pub(crate) fn announce(&self) -> Announce {
1078		let len = self.path.as_str().len() as u64;
1079		if let Some(counters) = self.counters() {
1080			counters.announced.fetch_add(1, Ordering::Relaxed);
1081			counters.announced_bytes.fetch_add(len, Ordering::Relaxed);
1082		}
1083		Announce {
1084			counters: self.counters.clone(),
1085			side: self.side,
1086			len,
1087		}
1088	}
1089}
1090
1091/// RAII guard for a track subscription (either side). See [`Scope::subscribe`].
1092/// [`Subscription::default`] is an empty no-op guard.
1093#[derive(Default)]
1094#[must_use = "drop the guard to record the subscription as closed"]
1095pub(crate) struct Subscription {
1096	counters: Option<Arc<TierCounters>>,
1097	side: Side,
1098	/// `Some((session, path))` on the egress side, to release the viewer refcount.
1099	viewer: Option<(Session, PathOwned)>,
1100}
1101
1102impl Drop for Subscription {
1103	fn drop(&mut self) {
1104		if let Some((session, path)) = &self.viewer
1105			&& session.viewer_close(path)
1106			&& let Some(counters) = &self.counters
1107		{
1108			// Release pairs with the readout's Acquire load of `broadcasts_closed`.
1109			self.side
1110				.counters(counters)
1111				.broadcasts_closed
1112				.fetch_add(1, Ordering::Release);
1113		}
1114		if let Some(counters) = &self.counters {
1115			// Release pairs with the readout's Acquire load of `subscriptions_closed`.
1116			self.side
1117				.counters(counters)
1118				.subscriptions_closed
1119				.fetch_add(1, Ordering::Release);
1120		}
1121	}
1122}
1123
1124/// RAII guard for one announce lifetime. See [`Scope::announce`].
1125#[must_use = "drop the guard to record the unannounce"]
1126pub(crate) struct Announce {
1127	counters: Option<Arc<TierCounters>>,
1128	side: Side,
1129	len: u64,
1130}
1131
1132impl Drop for Announce {
1133	fn drop(&mut self) {
1134		if let Some(counters) = &self.counters {
1135			let counters = self.side.counters(counters);
1136			counters.announced_bytes.fetch_add(self.len, Ordering::Relaxed);
1137			// Release pairs with the readout's Acquire load of `announced_closed`.
1138			counters.announced_closed.fetch_add(1, Ordering::Release);
1139		}
1140	}
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145	use std::sync::{Arc, atomic::Ordering::Relaxed};
1146
1147	use super::*;
1148
1149	#[test]
1150	fn default_tier_has_empty_label() {
1151		let tier = Tier::default();
1152		assert_eq!(tier.as_str(), "");
1153		assert_eq!(tier.to_string(), "");
1154		assert_eq!(tier.track_name("publisher.json"), "publisher.json");
1155	}
1156
1157	/// Counters for `(path, tier)`, creating the tier slot if absent.
1158	fn tier_counters(stats: &Registry, path: &str, tier: &Tier) -> Arc<TierCounters> {
1159		stats
1160			.shared()
1161			.entries
1162			.lock()
1163			.get(&PathOwned::from(path.to_string()))
1164			.expect("entry")
1165			.tier(tier)
1166	}
1167
1168	/// The [`Presence`] for `(tier, root)`, or `None` if absent.
1169	fn session_snapshot(stats: &Registry, tier: &Tier, root: &str) -> Option<Presence> {
1170		stats
1171			.shared()
1172			.sessions
1173			.lock()
1174			.get(tier)
1175			.and_then(|roots| roots.get(&PathOwned::from(root.to_string())).map(|c| c.snapshot()))
1176	}
1177
1178	fn test_stats() -> Registry {
1179		Registry::new(Config::new().with_exclude(".stats"))
1180	}
1181
1182	#[test]
1183	fn default_and_named_tiers_are_independent() {
1184		let stats = test_stats();
1185		let default = stats.tier(Tier::default()).session("root");
1186		let regional = stats.tier(Tier::new("region/sjc")).session("root");
1187
1188		default.egress("demo/bbb").meter().bytes(100);
1189		regional.ingress("demo/bbb").meter().bytes(7);
1190
1191		let default_counters = tier_counters(&stats, "demo/bbb", &Tier::default());
1192		let regional_counters = tier_counters(&stats, "demo/bbb", &Tier::new("region/sjc"));
1193		assert_eq!(default_counters.publisher.bytes.load(Relaxed), 100);
1194		assert_eq!(default_counters.subscriber.bytes.load(Relaxed), 0);
1195		assert_eq!(regional_counters.publisher.bytes.load(Relaxed), 0);
1196		assert_eq!(regional_counters.subscriber.bytes.load(Relaxed), 7);
1197	}
1198
1199	#[test]
1200	fn snapshot_rolls_up_by_tier_role_and_sessions() {
1201		let stats = test_stats();
1202		let default = stats.tier(Tier::default());
1203		let regional = stats.tier(Tier::new("region/sjc"));
1204
1205		// Two default-tier sessions under one root, one regional; presence sums them.
1206		let s1 = default.session("acme");
1207		let _s2 = default.session("acme");
1208		let s3 = regional.session("peer");
1209
1210		// Default-tier egress across two broadcasts; the snapshot sums them.
1211		{
1212			let m = s1.egress("demo/aaa").meter();
1213			m.bytes(100);
1214			m.frames(1);
1215			m.group();
1216		}
1217		s1.egress("demo/bbb").meter().bytes(50);
1218		// Regional ingress on a different tier/role stays isolated.
1219		s3.ingress("demo/aaa").meter().bytes(7);
1220
1221		let snap = stats.snapshot();
1222
1223		let slot = |tier, role| {
1224			snap.traffic()
1225				.into_iter()
1226				.find(|(t, r, _)| *t == tier && *r == role)
1227				.map(|(_, _, c)| c)
1228				.expect("row present")
1229		};
1230
1231		let default_publisher = slot(Tier::default(), Role::Publisher);
1232		assert_eq!(
1233			default_publisher.bytes, 150,
1234			"default egress bytes sum across broadcasts"
1235		);
1236		assert_eq!(default_publisher.frames, 1);
1237		assert_eq!(default_publisher.groups, 1);
1238
1239		let regional_subscriber = slot(Tier::new("region/sjc"), Role::Subscriber);
1240		assert_eq!(regional_subscriber.bytes, 7, "regional ingress isolated by tier/role");
1241		assert_eq!(slot(Tier::default(), Role::Subscriber).bytes, 0);
1242		assert_eq!(slot(Tier::new("region/sjc"), Role::Publisher).bytes, 0);
1243
1244		let sessions = |tier| {
1245			snap.sessions()
1246				.into_iter()
1247				.find(|(t, _)| *t == tier)
1248				.map(|(_, s)| s)
1249				.expect("tier present")
1250		};
1251		let default_sessions = sessions(Tier::default());
1252		assert_eq!(default_sessions.sessions, 2, "two default-tier sessions under one root");
1253		assert_eq!(default_sessions.sessions_closed, 0, "guards still held");
1254		assert_eq!(sessions(Tier::new("region/sjc")).sessions, 1);
1255	}
1256
1257	#[test]
1258	fn report_returns_detail_and_prunes() {
1259		// report() surfaces per-broadcast rows while a guard is held, keeps the
1260		// entry across drains while live, and prunes it on the first drain
1261		// after the last guard drops (returning the final values that once).
1262		let stats = test_stats();
1263		let key = PathOwned::from("foo/bar");
1264		let ctx = stats.tier(Tier::default()).session("root");
1265		let scope = ctx.egress("foo/bar");
1266		let sub = scope.subscribe();
1267		scope.meter().bytes(42);
1268
1269		let report = stats.report();
1270		let row = report
1271			.traffic
1272			.iter()
1273			.find(|row| row.path == key)
1274			.expect("live entry present");
1275		assert_eq!(row.publisher.bytes, 42);
1276		assert_eq!(row.publisher.subscriptions, 1);
1277		assert!(!row.publisher.is_idle(), "subscription guard still open");
1278		assert!(
1279			stats.shared().entries.lock().contains_key(&key),
1280			"live entry kept across drains"
1281		);
1282
1283		drop(sub);
1284		drop(scope);
1285
1286		// The drain after the last guard drops still returns the final values,
1287		// then prunes the entry.
1288		let report = stats.report();
1289		let row = report
1290			.traffic
1291			.iter()
1292			.find(|row| row.path == key)
1293			.expect("closing values still reported once");
1294		assert_eq!(row.publisher.subscriptions_closed, 1);
1295		assert!(row.publisher.is_idle());
1296		assert!(
1297			!stats.shared().entries.lock().contains_key(&key),
1298			"fully-closed entry pruned"
1299		);
1300		assert!(stats.report().traffic.is_empty(), "nothing left after the prune");
1301	}
1302
1303	#[test]
1304	fn report_keeps_idle_but_announced_entry() {
1305		// A broadcast with a live announce guard but no traffic must stay in
1306		// the registry indefinitely: announced != announced_closed means a
1307		// subscription could still begin at any moment.
1308		let stats = test_stats();
1309		let key = PathOwned::from("foo/bar");
1310		let ctx = stats.tier(Tier::default()).session("root");
1311		let scope = ctx.egress("foo/bar");
1312		let guard = scope.announce();
1313
1314		for _ in 0..3 {
1315			let report = stats.report();
1316			assert!(
1317				report.traffic.iter().any(|row| row.path == key),
1318				"announced-but-idle broadcast stays while the guard is held"
1319			);
1320		}
1321
1322		drop(guard);
1323		drop(scope);
1324		let report = stats.report();
1325		let row = report.traffic.iter().find(|row| row.path == key).expect("final report");
1326		assert!(row.publisher.is_idle());
1327		assert!(!stats.shared().entries.lock().contains_key(&key));
1328	}
1329
1330	#[test]
1331	fn report_prunes_empty_session_roots() {
1332		// Once the last session under a root disconnects, the root leaves the
1333		// registry on the drain that reports its final gauge.
1334		let stats = test_stats();
1335		let session = stats.tier(Tier::default()).session("acme");
1336
1337		let report = stats.report();
1338		let row = report
1339			.sessions
1340			.iter()
1341			.find(|row| row.root.as_str() == "acme")
1342			.expect("root present");
1343		assert_eq!(row.presence.active(), 1);
1344
1345		drop(session);
1346		let report = stats.report();
1347		let row = report
1348			.sessions
1349			.iter()
1350			.find(|row| row.root.as_str() == "acme")
1351			.expect("final gauge reported once");
1352		assert_eq!(row.presence.active(), 0);
1353		assert!(stats.report().sessions.is_empty(), "root pruned after the last drain");
1354		assert!(session_snapshot(&stats, &Tier::default(), "acme").is_none());
1355	}
1356
1357	#[test]
1358	fn paths_under_exclude_are_no_op() {
1359		// Our own stats broadcasts (and any sibling category under the same
1360		// prefix) must not feed back into the registry.
1361		let stats = test_stats();
1362		let ctx = stats.tier(Tier::default()).session("root");
1363		let scope = ctx.egress(".stats/node/sjc");
1364		scope.meter().bytes(100);
1365		let _guard = scope.announce();
1366		let _sub = scope.subscribe();
1367		assert!(stats.shared().entries.lock().is_empty());
1368	}
1369
1370	#[test]
1371	fn disabled_stats_are_noop() {
1372		// A disabled registry allocates no shared state; every handle is empty
1373		// and bumps are dropped.
1374		let stats = Registry::default();
1375		assert!(stats.shared.is_none());
1376		let ctx = stats.tier(Tier::default()).session("root");
1377		let scope = ctx.egress("demo/bbb");
1378		scope.meter().bytes(100);
1379		let _guard = scope.announce();
1380		let _sub = scope.subscribe();
1381		assert!(stats.report().traffic.is_empty());
1382		assert!(stats.snapshot().traffic().is_empty());
1383	}
1384
1385	#[test]
1386	fn session_counts_by_root() {
1387		// session() counts connected sessions per auth root, independent of any
1388		// broadcast: open bumps `sessions`, drop bumps `sessions_closed`.
1389		let stats = test_stats();
1390		let ext = stats.tier(Tier::default());
1391
1392		let snap =
1393			|root: &str| session_snapshot(&stats, &Tier::default(), root).map(|p| (p.sessions, p.sessions_closed));
1394
1395		let a1 = ext.session("acme");
1396		let a2 = ext.session("acme");
1397		let b1 = ext.session("globex");
1398		assert_eq!(snap("acme"), Some((2, 0)), "two sessions under one root");
1399		assert_eq!(snap("globex"), Some((1, 0)), "a distinct root is counted separately");
1400
1401		drop(a1);
1402		assert_eq!(snap("acme"), Some((2, 1)));
1403		drop(a2);
1404		drop(b1);
1405		assert_eq!(snap("acme"), Some((2, 2)));
1406		assert_eq!(snap("globex"), Some((1, 1)));
1407	}
1408
1409	#[test]
1410	fn traffic_parses_with_missing_and_unknown_fields() {
1411		// Wire forward/backward compat: a frame entry from an older publisher
1412		// (missing fields) or a newer one (extra fields) must still parse.
1413		let old: Traffic = serde_json::from_str(r#"{"announced":1,"bytes":5}"#).expect("older shape parses");
1414		assert_eq!(old.announced, 1);
1415		assert_eq!(old.bytes, 5);
1416		assert_eq!(old.announced_bytes, 0, "missing fields default to zero");
1417
1418		let new: Traffic = serde_json::from_str(r#"{"announced":1,"announced_closed":1,"future_counter":9}"#)
1419			.expect("newer shape parses");
1420		assert!(new.is_idle());
1421	}
1422
1423	#[test]
1424	fn snapshot_reads_closed_before_open() {
1425		// Reading closed counters before their open counterparts is the
1426		// guarantee that a readout never shows close > open under concurrent
1427		// bumps. This unit-test pins the ordering at the source level so a
1428		// future refactor that re-orders the loads trips the test.
1429		let src = include_str!("stats.rs");
1430		// Find the body of `impl Counters { fn snapshot(...) ... }` and
1431		// check the line order.
1432		let body_start = src.find("fn snapshot(&self) -> Traffic").expect("snapshot fn present");
1433		let body = &src[body_start..];
1434		let closed_pos = body.find("self.announced_closed.load").expect("announced_closed load");
1435		let open_pos = body.find("self.announced.load(").expect("announced load");
1436		assert!(
1437			closed_pos < open_pos,
1438			"announced_closed must be loaded before announced; reversing breaks the open>=closed invariant",
1439		);
1440		let subs_closed_pos = body
1441			.find("self.subscriptions_closed.load")
1442			.expect("subscriptions_closed load");
1443		let subs_pos = body.find("self.subscriptions.load").expect("subscriptions load");
1444		assert!(
1445			subs_closed_pos < subs_pos,
1446			"subscriptions_closed must be loaded before subscriptions",
1447		);
1448		let bcast_closed_pos = body
1449			.find("self.broadcasts_closed.load")
1450			.expect("broadcasts_closed load");
1451		let bcast_pos = body.find("self.broadcasts.load").expect("broadcasts load");
1452		assert!(
1453			bcast_closed_pos < bcast_pos,
1454			"broadcasts_closed must be loaded before broadcasts",
1455		);
1456	}
1457
1458	#[test]
1459	fn context_presence_closes_on_last_clone() {
1460		// The reshaped Session context bumps `sessions` once at creation and
1461		// `sessions_closed` only when the last clone drops.
1462		let stats = test_stats();
1463		let snap =
1464			|root: &str| session_snapshot(&stats, &Tier::default(), root).map(|p| (p.sessions, p.sessions_closed));
1465
1466		let ctx = stats.tier(Tier::default()).session("acme");
1467		assert_eq!(snap("acme"), Some((1, 0)));
1468
1469		let clone = ctx.clone();
1470		// A clone shares the Arc: no extra `sessions`, and dropping one does nothing.
1471		assert_eq!(snap("acme"), Some((1, 0)));
1472		drop(ctx);
1473		assert_eq!(snap("acme"), Some((1, 0)));
1474		drop(clone);
1475		assert_eq!(snap("acme"), Some((1, 1)));
1476	}
1477
1478	#[test]
1479	fn meter_bumps_the_right_side() {
1480		// A payload meter records on its own side only.
1481		let stats = test_stats();
1482		let ctx = stats.tier(Tier::default()).session("root");
1483
1484		let egress = ctx.egress("demo/bbb").meter();
1485		egress.group();
1486		egress.frames(3);
1487		egress.bytes(100);
1488
1489		let ingress = ctx.ingress("demo/bbb").meter();
1490		ingress.group();
1491		ingress.frames(1);
1492		ingress.bytes(7);
1493
1494		let counters = tier_counters(&stats, "demo/bbb", &Tier::default());
1495		let pub_ = counters.publisher.snapshot();
1496		let sub = counters.subscriber.snapshot();
1497		assert_eq!((pub_.groups, pub_.frames, pub_.bytes), (1, 3, 100));
1498		assert_eq!((sub.groups, sub.frames, sub.bytes), (1, 1, 7));
1499	}
1500
1501	#[test]
1502	fn egress_subscribe_drives_subscriptions_and_viewers() {
1503		// An egress subscription bumps `subscriptions` and, being the context's first
1504		// for the broadcast, `broadcasts`. Dropping closes both.
1505		let stats = test_stats();
1506		let ctx = stats.tier(Tier::default()).session("root");
1507		let raw = || tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1508
1509		let scope = ctx.egress("demo/bbb");
1510		let s1 = scope.subscribe();
1511		let s2 = scope.subscribe();
1512		let r = raw();
1513		assert_eq!(r.subscriptions, 2, "two track subs");
1514		assert_eq!(r.broadcasts, 1, "one context => one viewer");
1515		assert_eq!(r.broadcasts_closed, 0);
1516
1517		drop(s1);
1518		assert_eq!(raw().broadcasts_closed, 0, "context still has a sub open");
1519		drop(s2);
1520		let r = raw();
1521		assert_eq!(r.subscriptions_closed, 2);
1522		assert_eq!(r.broadcasts_closed, 1, "last sub closed => one broadcasts_closed");
1523	}
1524
1525	#[test]
1526	fn distinct_contexts_are_distinct_viewers() {
1527		// Two contexts (sessions) subscribing to the same broadcast are two viewers.
1528		let stats = test_stats();
1529		let raw = || tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1530
1531		let v1 = stats.tier(Tier::default()).session("a").egress("demo/bbb").subscribe();
1532		assert_eq!(raw().broadcasts, 1);
1533		let v2 = stats.tier(Tier::default()).session("b").egress("demo/bbb").subscribe();
1534		assert_eq!(raw().broadcasts, 2, "two distinct contexts => two viewers");
1535
1536		drop(v1);
1537		assert_eq!(raw().active_broadcasts(), 1);
1538		drop(v2);
1539		assert_eq!(raw().broadcasts_closed, 2);
1540	}
1541
1542	#[test]
1543	fn ingress_subscription_has_no_viewer() {
1544		// An ingress (producer-lifetime) subscription bumps subscriptions but never
1545		// the viewer refcount, which is egress-only.
1546		let stats = test_stats();
1547		let ctx = stats.tier(Tier::default()).session("root");
1548		let guard = ctx.ingress("demo/bbb").subscribe();
1549		let sub = tier_counters(&stats, "demo/bbb", &Tier::default())
1550			.subscriber
1551			.snapshot();
1552		assert_eq!(sub.subscriptions, 1);
1553		assert_eq!(sub.broadcasts, 0, "ingress has no viewer refcount");
1554		drop(guard);
1555		assert_eq!(
1556			tier_counters(&stats, "demo/bbb", &Tier::default())
1557				.subscriber
1558				.snapshot()
1559				.subscriptions_closed,
1560			1
1561		);
1562	}
1563
1564	#[test]
1565	fn fetch_counts_separately_from_subscriptions() {
1566		// A fetch bumps `fetches`, not `subscriptions` or the viewer refcount.
1567		let stats = test_stats();
1568		let ctx = stats.tier(Tier::default()).session("root");
1569		let scope = ctx.egress("demo/bbb");
1570		scope.fetch();
1571		scope.fetch();
1572		let r = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1573		assert_eq!(r.fetches, 2);
1574		assert_eq!(r.subscriptions, 0);
1575		assert_eq!(r.broadcasts, 0);
1576	}
1577
1578	#[test]
1579	fn announce_guard_records_bytes_on_open_and_close() {
1580		// The announce guard bumps `announced` + the path length on open, and
1581		// `announced_closed` + the path length again on drop.
1582		let stats = test_stats();
1583		let ctx = stats.tier(Tier::default()).session("root");
1584		let path_len = "demo/bbb".len() as u64;
1585
1586		let guard = ctx.egress("demo/bbb").announce();
1587		let r = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1588		assert_eq!(r.announced, 1);
1589		assert_eq!(r.announced_closed, 0);
1590		assert_eq!(r.announced_bytes, path_len);
1591
1592		drop(guard);
1593		let r = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1594		assert_eq!(r.announced_closed, 1);
1595		assert_eq!(
1596			r.announced_bytes,
1597			path_len * 2,
1598			"path length recorded on open and close"
1599		);
1600	}
1601
1602	#[test]
1603	fn disabled_context_is_noop() {
1604		// A default (disabled) context resolves empty scopes: every bump is dropped.
1605		let ctx = Session::default();
1606		let scope = ctx.egress("demo/bbb");
1607		scope.meter().bytes(100);
1608		let _guard = scope.announce();
1609		let _sub = scope.subscribe();
1610		scope.fetch();
1611		// No registry to inspect; the point is that none of this panics or allocates.
1612		assert!(ctx.inner.is_none());
1613	}
1614
1615	#[test]
1616	fn fetches_serde_roundtrips() {
1617		// The new `fetches` field is additive: an older frame omits it (defaults to
1618		// zero), and it survives a roundtrip.
1619		let old: Traffic = serde_json::from_str(r#"{"bytes":5}"#).expect("older shape parses");
1620		assert_eq!(old.fetches, 0);
1621
1622		let t = Traffic {
1623			fetches: 9,
1624			..Default::default()
1625		};
1626		let json = serde_json::to_string(&t).unwrap();
1627		let back: Traffic = serde_json::from_str(&json).unwrap();
1628		assert_eq!(back.fetches, 9);
1629	}
1630
1631	#[test]
1632	fn session_snapshot_reads_closed_before_open() {
1633		// Same `closed`-before-`open` invariant as `Counters::snapshot`, pinned
1634		// at the source level so a reordering refactor can't let
1635		// `sessions_closed > sessions` leak into a readout.
1636		let src = include_str!("stats.rs");
1637		let body_start = src
1638			.find("fn snapshot(&self) -> Presence")
1639			.expect("SessionCounters::snapshot fn present");
1640		let body = &src[body_start..];
1641		let closed_pos = body.find("self.sessions_closed.load").expect("sessions_closed load");
1642		let open_pos = body.find("self.sessions.load").expect("sessions load");
1643		assert!(closed_pos < open_pos, "sessions_closed must be loaded before sessions",);
1644	}
1645}