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	/// Bump `subscriptions` once. The ingress (producer-lifetime) counterpart to
1074	/// [`Self::subscribe`], which cannot use an RAII guard because the producer is
1075	/// cloneable and closes only on the last clone drop. No viewer refcount (that is
1076	/// egress-only). Pair with [`Self::close_subscription`].
1077	pub(crate) fn open_subscription(&self) {
1078		if let Some(counters) = self.counters() {
1079			counters.subscriptions.fetch_add(1, Ordering::Relaxed);
1080		}
1081	}
1082
1083	/// Bump `subscriptions_closed` once. See [`Self::open_subscription`].
1084	pub(crate) fn close_subscription(&self) {
1085		if let Some(counters) = self.counters() {
1086			// Release pairs with the readout's Acquire load of `subscriptions_closed`.
1087			counters.subscriptions_closed.fetch_add(1, Ordering::Release);
1088		}
1089	}
1090
1091	/// Open an announce guard: bumps `announced` and adds the path length to
1092	/// `announced_bytes` now; on drop bumps `announced_closed` and adds the path
1093	/// length again. Used for egress announce-stream events and ingress
1094	/// route-transition (un)announces.
1095	pub(crate) fn announce(&self) -> Announce {
1096		let len = self.path.as_str().len() as u64;
1097		if let Some(counters) = self.counters() {
1098			counters.announced.fetch_add(1, Ordering::Relaxed);
1099			counters.announced_bytes.fetch_add(len, Ordering::Relaxed);
1100		}
1101		Announce {
1102			counters: self.counters.clone(),
1103			side: self.side,
1104			len,
1105		}
1106	}
1107}
1108
1109/// RAII guard for a track subscription (either side). See [`Scope::subscribe`].
1110/// [`Subscription::default`] is an empty no-op guard.
1111#[derive(Default)]
1112#[must_use = "drop the guard to record the subscription as closed"]
1113pub(crate) struct Subscription {
1114	counters: Option<Arc<TierCounters>>,
1115	side: Side,
1116	/// `Some((session, path))` on the egress side, to release the viewer refcount.
1117	viewer: Option<(Session, PathOwned)>,
1118}
1119
1120impl Drop for Subscription {
1121	fn drop(&mut self) {
1122		if let Some((session, path)) = &self.viewer
1123			&& session.viewer_close(path)
1124			&& let Some(counters) = &self.counters
1125		{
1126			// Release pairs with the readout's Acquire load of `broadcasts_closed`.
1127			self.side
1128				.counters(counters)
1129				.broadcasts_closed
1130				.fetch_add(1, Ordering::Release);
1131		}
1132		if let Some(counters) = &self.counters {
1133			// Release pairs with the readout's Acquire load of `subscriptions_closed`.
1134			self.side
1135				.counters(counters)
1136				.subscriptions_closed
1137				.fetch_add(1, Ordering::Release);
1138		}
1139	}
1140}
1141
1142/// RAII guard for one announce lifetime. See [`Scope::announce`].
1143#[must_use = "drop the guard to record the unannounce"]
1144pub(crate) struct Announce {
1145	counters: Option<Arc<TierCounters>>,
1146	side: Side,
1147	len: u64,
1148}
1149
1150impl Drop for Announce {
1151	fn drop(&mut self) {
1152		if let Some(counters) = &self.counters {
1153			let counters = self.side.counters(counters);
1154			counters.announced_bytes.fetch_add(self.len, Ordering::Relaxed);
1155			// Release pairs with the readout's Acquire load of `announced_closed`.
1156			counters.announced_closed.fetch_add(1, Ordering::Release);
1157		}
1158	}
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163	use std::sync::{Arc, atomic::Ordering::Relaxed};
1164
1165	use super::*;
1166
1167	#[test]
1168	fn default_tier_has_empty_label() {
1169		let tier = Tier::default();
1170		assert_eq!(tier.as_str(), "");
1171		assert_eq!(tier.to_string(), "");
1172		assert_eq!(tier.track_name("publisher.json"), "publisher.json");
1173	}
1174
1175	/// Counters for `(path, tier)`, creating the tier slot if absent.
1176	fn tier_counters(stats: &Registry, path: &str, tier: &Tier) -> Arc<TierCounters> {
1177		stats
1178			.shared()
1179			.entries
1180			.lock()
1181			.get(&PathOwned::from(path.to_string()))
1182			.expect("entry")
1183			.tier(tier)
1184	}
1185
1186	/// The [`Presence`] for `(tier, root)`, or `None` if absent.
1187	fn session_snapshot(stats: &Registry, tier: &Tier, root: &str) -> Option<Presence> {
1188		stats
1189			.shared()
1190			.sessions
1191			.lock()
1192			.get(tier)
1193			.and_then(|roots| roots.get(&PathOwned::from(root.to_string())).map(|c| c.snapshot()))
1194	}
1195
1196	fn test_stats() -> Registry {
1197		Registry::new(Config::new().with_exclude(".stats"))
1198	}
1199
1200	#[test]
1201	fn default_and_named_tiers_are_independent() {
1202		let stats = test_stats();
1203		let default = stats.tier(Tier::default()).session("root");
1204		let regional = stats.tier(Tier::new("region/sjc")).session("root");
1205
1206		default.egress("demo/bbb").meter().bytes(100);
1207		regional.ingress("demo/bbb").meter().bytes(7);
1208
1209		let default_counters = tier_counters(&stats, "demo/bbb", &Tier::default());
1210		let regional_counters = tier_counters(&stats, "demo/bbb", &Tier::new("region/sjc"));
1211		assert_eq!(default_counters.publisher.bytes.load(Relaxed), 100);
1212		assert_eq!(default_counters.subscriber.bytes.load(Relaxed), 0);
1213		assert_eq!(regional_counters.publisher.bytes.load(Relaxed), 0);
1214		assert_eq!(regional_counters.subscriber.bytes.load(Relaxed), 7);
1215	}
1216
1217	#[test]
1218	fn snapshot_rolls_up_by_tier_role_and_sessions() {
1219		let stats = test_stats();
1220		let default = stats.tier(Tier::default());
1221		let regional = stats.tier(Tier::new("region/sjc"));
1222
1223		// Two default-tier sessions under one root, one regional; presence sums them.
1224		let s1 = default.session("acme");
1225		let _s2 = default.session("acme");
1226		let s3 = regional.session("peer");
1227
1228		// Default-tier egress across two broadcasts; the snapshot sums them.
1229		{
1230			let m = s1.egress("demo/aaa").meter();
1231			m.bytes(100);
1232			m.frames(1);
1233			m.group();
1234		}
1235		s1.egress("demo/bbb").meter().bytes(50);
1236		// Regional ingress on a different tier/role stays isolated.
1237		s3.ingress("demo/aaa").meter().bytes(7);
1238
1239		let snap = stats.snapshot();
1240
1241		let slot = |tier, role| {
1242			snap.traffic()
1243				.into_iter()
1244				.find(|(t, r, _)| *t == tier && *r == role)
1245				.map(|(_, _, c)| c)
1246				.expect("row present")
1247		};
1248
1249		let default_publisher = slot(Tier::default(), Role::Publisher);
1250		assert_eq!(
1251			default_publisher.bytes, 150,
1252			"default egress bytes sum across broadcasts"
1253		);
1254		assert_eq!(default_publisher.frames, 1);
1255		assert_eq!(default_publisher.groups, 1);
1256
1257		let regional_subscriber = slot(Tier::new("region/sjc"), Role::Subscriber);
1258		assert_eq!(regional_subscriber.bytes, 7, "regional ingress isolated by tier/role");
1259		assert_eq!(slot(Tier::default(), Role::Subscriber).bytes, 0);
1260		assert_eq!(slot(Tier::new("region/sjc"), Role::Publisher).bytes, 0);
1261
1262		let sessions = |tier| {
1263			snap.sessions()
1264				.into_iter()
1265				.find(|(t, _)| *t == tier)
1266				.map(|(_, s)| s)
1267				.expect("tier present")
1268		};
1269		let default_sessions = sessions(Tier::default());
1270		assert_eq!(default_sessions.sessions, 2, "two default-tier sessions under one root");
1271		assert_eq!(default_sessions.sessions_closed, 0, "guards still held");
1272		assert_eq!(sessions(Tier::new("region/sjc")).sessions, 1);
1273	}
1274
1275	#[test]
1276	fn report_returns_detail_and_prunes() {
1277		// report() surfaces per-broadcast rows while a guard is held, keeps the
1278		// entry across drains while live, and prunes it on the first drain
1279		// after the last guard drops (returning the final values that once).
1280		let stats = test_stats();
1281		let key = PathOwned::from("foo/bar");
1282		let ctx = stats.tier(Tier::default()).session("root");
1283		let scope = ctx.egress("foo/bar");
1284		let sub = scope.subscribe();
1285		scope.meter().bytes(42);
1286
1287		let report = stats.report();
1288		let row = report
1289			.traffic
1290			.iter()
1291			.find(|row| row.path == key)
1292			.expect("live entry present");
1293		assert_eq!(row.publisher.bytes, 42);
1294		assert_eq!(row.publisher.subscriptions, 1);
1295		assert!(!row.publisher.is_idle(), "subscription guard still open");
1296		assert!(
1297			stats.shared().entries.lock().contains_key(&key),
1298			"live entry kept across drains"
1299		);
1300
1301		drop(sub);
1302		drop(scope);
1303
1304		// The drain after the last guard drops still returns the final values,
1305		// then prunes the entry.
1306		let report = stats.report();
1307		let row = report
1308			.traffic
1309			.iter()
1310			.find(|row| row.path == key)
1311			.expect("closing values still reported once");
1312		assert_eq!(row.publisher.subscriptions_closed, 1);
1313		assert!(row.publisher.is_idle());
1314		assert!(
1315			!stats.shared().entries.lock().contains_key(&key),
1316			"fully-closed entry pruned"
1317		);
1318		assert!(stats.report().traffic.is_empty(), "nothing left after the prune");
1319	}
1320
1321	#[test]
1322	fn report_keeps_idle_but_announced_entry() {
1323		// A broadcast with a live announce guard but no traffic must stay in
1324		// the registry indefinitely: announced != announced_closed means a
1325		// subscription could still begin at any moment.
1326		let stats = test_stats();
1327		let key = PathOwned::from("foo/bar");
1328		let ctx = stats.tier(Tier::default()).session("root");
1329		let scope = ctx.egress("foo/bar");
1330		let guard = scope.announce();
1331
1332		for _ in 0..3 {
1333			let report = stats.report();
1334			assert!(
1335				report.traffic.iter().any(|row| row.path == key),
1336				"announced-but-idle broadcast stays while the guard is held"
1337			);
1338		}
1339
1340		drop(guard);
1341		drop(scope);
1342		let report = stats.report();
1343		let row = report.traffic.iter().find(|row| row.path == key).expect("final report");
1344		assert!(row.publisher.is_idle());
1345		assert!(!stats.shared().entries.lock().contains_key(&key));
1346	}
1347
1348	#[test]
1349	fn report_prunes_empty_session_roots() {
1350		// Once the last session under a root disconnects, the root leaves the
1351		// registry on the drain that reports its final gauge.
1352		let stats = test_stats();
1353		let session = stats.tier(Tier::default()).session("acme");
1354
1355		let report = stats.report();
1356		let row = report
1357			.sessions
1358			.iter()
1359			.find(|row| row.root.as_str() == "acme")
1360			.expect("root present");
1361		assert_eq!(row.presence.active(), 1);
1362
1363		drop(session);
1364		let report = stats.report();
1365		let row = report
1366			.sessions
1367			.iter()
1368			.find(|row| row.root.as_str() == "acme")
1369			.expect("final gauge reported once");
1370		assert_eq!(row.presence.active(), 0);
1371		assert!(stats.report().sessions.is_empty(), "root pruned after the last drain");
1372		assert!(session_snapshot(&stats, &Tier::default(), "acme").is_none());
1373	}
1374
1375	#[test]
1376	fn paths_under_exclude_are_no_op() {
1377		// Our own stats broadcasts (and any sibling category under the same
1378		// prefix) must not feed back into the registry.
1379		let stats = test_stats();
1380		let ctx = stats.tier(Tier::default()).session("root");
1381		let scope = ctx.egress(".stats/node/sjc");
1382		scope.meter().bytes(100);
1383		let _guard = scope.announce();
1384		let _sub = scope.subscribe();
1385		assert!(stats.shared().entries.lock().is_empty());
1386	}
1387
1388	#[test]
1389	fn disabled_stats_are_noop() {
1390		// A disabled registry allocates no shared state; every handle is empty
1391		// and bumps are dropped.
1392		let stats = Registry::default();
1393		assert!(stats.shared.is_none());
1394		let ctx = stats.tier(Tier::default()).session("root");
1395		let scope = ctx.egress("demo/bbb");
1396		scope.meter().bytes(100);
1397		let _guard = scope.announce();
1398		let _sub = scope.subscribe();
1399		assert!(stats.report().traffic.is_empty());
1400		assert!(stats.snapshot().traffic().is_empty());
1401	}
1402
1403	#[test]
1404	fn session_counts_by_root() {
1405		// session() counts connected sessions per auth root, independent of any
1406		// broadcast: open bumps `sessions`, drop bumps `sessions_closed`.
1407		let stats = test_stats();
1408		let ext = stats.tier(Tier::default());
1409
1410		let snap =
1411			|root: &str| session_snapshot(&stats, &Tier::default(), root).map(|p| (p.sessions, p.sessions_closed));
1412
1413		let a1 = ext.session("acme");
1414		let a2 = ext.session("acme");
1415		let b1 = ext.session("globex");
1416		assert_eq!(snap("acme"), Some((2, 0)), "two sessions under one root");
1417		assert_eq!(snap("globex"), Some((1, 0)), "a distinct root is counted separately");
1418
1419		drop(a1);
1420		assert_eq!(snap("acme"), Some((2, 1)));
1421		drop(a2);
1422		drop(b1);
1423		assert_eq!(snap("acme"), Some((2, 2)));
1424		assert_eq!(snap("globex"), Some((1, 1)));
1425	}
1426
1427	#[test]
1428	fn traffic_parses_with_missing_and_unknown_fields() {
1429		// Wire forward/backward compat: a frame entry from an older publisher
1430		// (missing fields) or a newer one (extra fields) must still parse.
1431		let old: Traffic = serde_json::from_str(r#"{"announced":1,"bytes":5}"#).expect("older shape parses");
1432		assert_eq!(old.announced, 1);
1433		assert_eq!(old.bytes, 5);
1434		assert_eq!(old.announced_bytes, 0, "missing fields default to zero");
1435
1436		let new: Traffic = serde_json::from_str(r#"{"announced":1,"announced_closed":1,"future_counter":9}"#)
1437			.expect("newer shape parses");
1438		assert!(new.is_idle());
1439	}
1440
1441	#[test]
1442	fn snapshot_reads_closed_before_open() {
1443		// Reading closed counters before their open counterparts is the
1444		// guarantee that a readout never shows close > open under concurrent
1445		// bumps. This unit-test pins the ordering at the source level so a
1446		// future refactor that re-orders the loads trips the test.
1447		let src = include_str!("stats.rs");
1448		// Find the body of `impl Counters { fn snapshot(...) ... }` and
1449		// check the line order.
1450		let body_start = src.find("fn snapshot(&self) -> Traffic").expect("snapshot fn present");
1451		let body = &src[body_start..];
1452		let closed_pos = body.find("self.announced_closed.load").expect("announced_closed load");
1453		let open_pos = body.find("self.announced.load(").expect("announced load");
1454		assert!(
1455			closed_pos < open_pos,
1456			"announced_closed must be loaded before announced; reversing breaks the open>=closed invariant",
1457		);
1458		let subs_closed_pos = body
1459			.find("self.subscriptions_closed.load")
1460			.expect("subscriptions_closed load");
1461		let subs_pos = body.find("self.subscriptions.load").expect("subscriptions load");
1462		assert!(
1463			subs_closed_pos < subs_pos,
1464			"subscriptions_closed must be loaded before subscriptions",
1465		);
1466		let bcast_closed_pos = body
1467			.find("self.broadcasts_closed.load")
1468			.expect("broadcasts_closed load");
1469		let bcast_pos = body.find("self.broadcasts.load").expect("broadcasts load");
1470		assert!(
1471			bcast_closed_pos < bcast_pos,
1472			"broadcasts_closed must be loaded before broadcasts",
1473		);
1474	}
1475
1476	#[test]
1477	fn context_presence_closes_on_last_clone() {
1478		// The reshaped Session context bumps `sessions` once at creation and
1479		// `sessions_closed` only when the last clone drops.
1480		let stats = test_stats();
1481		let snap =
1482			|root: &str| session_snapshot(&stats, &Tier::default(), root).map(|p| (p.sessions, p.sessions_closed));
1483
1484		let ctx = stats.tier(Tier::default()).session("acme");
1485		assert_eq!(snap("acme"), Some((1, 0)));
1486
1487		let clone = ctx.clone();
1488		// A clone shares the Arc: no extra `sessions`, and dropping one does nothing.
1489		assert_eq!(snap("acme"), Some((1, 0)));
1490		drop(ctx);
1491		assert_eq!(snap("acme"), Some((1, 0)));
1492		drop(clone);
1493		assert_eq!(snap("acme"), Some((1, 1)));
1494	}
1495
1496	#[test]
1497	fn meter_bumps_the_right_side() {
1498		// A payload meter records on its own side only.
1499		let stats = test_stats();
1500		let ctx = stats.tier(Tier::default()).session("root");
1501
1502		let egress = ctx.egress("demo/bbb").meter();
1503		egress.group();
1504		egress.frames(3);
1505		egress.bytes(100);
1506
1507		let ingress = ctx.ingress("demo/bbb").meter();
1508		ingress.group();
1509		ingress.frames(1);
1510		ingress.bytes(7);
1511
1512		let counters = tier_counters(&stats, "demo/bbb", &Tier::default());
1513		let pub_ = counters.publisher.snapshot();
1514		let sub = counters.subscriber.snapshot();
1515		assert_eq!((pub_.groups, pub_.frames, pub_.bytes), (1, 3, 100));
1516		assert_eq!((sub.groups, sub.frames, sub.bytes), (1, 1, 7));
1517	}
1518
1519	#[test]
1520	fn egress_subscribe_drives_subscriptions_and_viewers() {
1521		// An egress subscription bumps `subscriptions` and, being the context's first
1522		// for the broadcast, `broadcasts`. Dropping closes both.
1523		let stats = test_stats();
1524		let ctx = stats.tier(Tier::default()).session("root");
1525		let raw = || tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1526
1527		let scope = ctx.egress("demo/bbb");
1528		let s1 = scope.subscribe();
1529		let s2 = scope.subscribe();
1530		let r = raw();
1531		assert_eq!(r.subscriptions, 2, "two track subs");
1532		assert_eq!(r.broadcasts, 1, "one context => one viewer");
1533		assert_eq!(r.broadcasts_closed, 0);
1534
1535		drop(s1);
1536		assert_eq!(raw().broadcasts_closed, 0, "context still has a sub open");
1537		drop(s2);
1538		let r = raw();
1539		assert_eq!(r.subscriptions_closed, 2);
1540		assert_eq!(r.broadcasts_closed, 1, "last sub closed => one broadcasts_closed");
1541	}
1542
1543	#[test]
1544	fn distinct_contexts_are_distinct_viewers() {
1545		// Two contexts (sessions) subscribing to the same broadcast are two viewers.
1546		let stats = test_stats();
1547		let raw = || tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1548
1549		let v1 = stats.tier(Tier::default()).session("a").egress("demo/bbb").subscribe();
1550		assert_eq!(raw().broadcasts, 1);
1551		let v2 = stats.tier(Tier::default()).session("b").egress("demo/bbb").subscribe();
1552		assert_eq!(raw().broadcasts, 2, "two distinct contexts => two viewers");
1553
1554		drop(v1);
1555		assert_eq!(raw().active_broadcasts(), 1);
1556		drop(v2);
1557		assert_eq!(raw().broadcasts_closed, 2);
1558	}
1559
1560	#[test]
1561	fn ingress_subscription_has_no_viewer() {
1562		// The ingress (producer-lifetime) subscription pair bumps subscriptions but
1563		// never the viewer refcount.
1564		let stats = test_stats();
1565		let ctx = stats.tier(Tier::default()).session("root");
1566		let scope = ctx.ingress("demo/bbb");
1567		scope.open_subscription();
1568		let sub = tier_counters(&stats, "demo/bbb", &Tier::default())
1569			.subscriber
1570			.snapshot();
1571		assert_eq!(sub.subscriptions, 1);
1572		assert_eq!(sub.broadcasts, 0, "ingress has no viewer refcount");
1573		scope.close_subscription();
1574		assert_eq!(
1575			tier_counters(&stats, "demo/bbb", &Tier::default())
1576				.subscriber
1577				.snapshot()
1578				.subscriptions_closed,
1579			1
1580		);
1581	}
1582
1583	#[test]
1584	fn fetch_counts_separately_from_subscriptions() {
1585		// A fetch bumps `fetches`, not `subscriptions` or the viewer refcount.
1586		let stats = test_stats();
1587		let ctx = stats.tier(Tier::default()).session("root");
1588		let scope = ctx.egress("demo/bbb");
1589		scope.fetch();
1590		scope.fetch();
1591		let r = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1592		assert_eq!(r.fetches, 2);
1593		assert_eq!(r.subscriptions, 0);
1594		assert_eq!(r.broadcasts, 0);
1595	}
1596
1597	#[test]
1598	fn announce_guard_records_bytes_on_open_and_close() {
1599		// The announce guard bumps `announced` + the path length on open, and
1600		// `announced_closed` + the path length again on drop.
1601		let stats = test_stats();
1602		let ctx = stats.tier(Tier::default()).session("root");
1603		let path_len = "demo/bbb".len() as u64;
1604
1605		let guard = ctx.egress("demo/bbb").announce();
1606		let r = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1607		assert_eq!(r.announced, 1);
1608		assert_eq!(r.announced_closed, 0);
1609		assert_eq!(r.announced_bytes, path_len);
1610
1611		drop(guard);
1612		let r = tier_counters(&stats, "demo/bbb", &Tier::default()).publisher.snapshot();
1613		assert_eq!(r.announced_closed, 1);
1614		assert_eq!(
1615			r.announced_bytes,
1616			path_len * 2,
1617			"path length recorded on open and close"
1618		);
1619	}
1620
1621	#[test]
1622	fn disabled_context_is_noop() {
1623		// A default (disabled) context resolves empty scopes: every bump is dropped.
1624		let ctx = Session::default();
1625		let scope = ctx.egress("demo/bbb");
1626		scope.meter().bytes(100);
1627		let _guard = scope.announce();
1628		let _sub = scope.subscribe();
1629		scope.fetch();
1630		// No registry to inspect; the point is that none of this panics or allocates.
1631		assert!(ctx.inner.is_none());
1632	}
1633
1634	#[test]
1635	fn fetches_serde_roundtrips() {
1636		// The new `fetches` field is additive: an older frame omits it (defaults to
1637		// zero), and it survives a roundtrip.
1638		let old: Traffic = serde_json::from_str(r#"{"bytes":5}"#).expect("older shape parses");
1639		assert_eq!(old.fetches, 0);
1640
1641		let t = Traffic {
1642			fetches: 9,
1643			..Default::default()
1644		};
1645		let json = serde_json::to_string(&t).unwrap();
1646		let back: Traffic = serde_json::from_str(&json).unwrap();
1647		assert_eq!(back.fetches, 9);
1648	}
1649
1650	#[test]
1651	fn session_snapshot_reads_closed_before_open() {
1652		// Same `closed`-before-`open` invariant as `Counters::snapshot`, pinned
1653		// at the source level so a reordering refactor can't let
1654		// `sessions_closed > sessions` leak into a readout.
1655		let src = include_str!("stats.rs");
1656		let body_start = src
1657			.find("fn snapshot(&self) -> Presence")
1658			.expect("SessionCounters::snapshot fn present");
1659		let body = &src[body_start..];
1660		let closed_pos = body.find("self.sessions_closed.load").expect("sessions_closed load");
1661		let open_pos = body.find("self.sessions.load").expect("sessions load");
1662		assert!(closed_pos < open_pos, "sessions_closed must be loaded before sessions",);
1663	}
1664}