Skip to main content

moq_net/
stats.rs

1//! Generic stats publishing for moq-net sessions.
2//!
3//! [`Stats`] aggregates per-broadcast counter bumps for traffic this relay
4//! node is handling and publishes them on a single `<prefix>/node/<node>`
5//! broadcast (or `<prefix>/node` when no node is configured). The broadcast
6//! carries four per-broadcast tracks, one per `(tier, role)` pair:
7//!
8//! * `publisher.json`           : external (e.g. customer) egress
9//! * `subscriber.json`          : external ingress
10//! * `internal/publisher.json`  : internal (e.g. mTLS cluster peer) egress
11//! * `internal/subscriber.json` : internal ingress
12//!
13//! plus two session tracks, one per tier, that count connected sessions
14//! keyed by auth root rather than broadcast:
15//!
16//! * `sessions.json`            : external sessions by root
17//! * `internal/sessions.json`   : internal sessions by root
18//!
19//! Each per-broadcast frame is a JSON object mapping broadcast path to a
20//! cumulative counter snapshot. Tier, role, and node are implied by the track
21//! and broadcast paths, so they aren't repeated inside the frame. An entry
22//! appears in the frame for a given `(tier, role)` on any tick where the
23//! broadcast is live (any open counter still exceeds its `*_closed`
24//! counterpart, so a subscription could begin at any moment) or its
25//! snapshot changed since the previous tick. Once every counter equals its
26//! `*_closed` counterpart no traffic can flow, so the entry is dropped. A
27//! downstream aggregator computes rates from successive cumulative
28//! snapshots and slices the data however a dashboard wants.
29//!
30//! Each session frame maps auth root to a `{ sessions, sessions_closed }`
31//! snapshot: `sessions` bumps when a session authenticated under that root
32//! connects, `sessions_closed` when it disconnects, so `sessions -
33//! sessions_closed` is the live session count for the root. This counts
34//! connected sessions regardless of whether any data flows, which is what
35//! presence-based billing wants. A root entry is emitted while live or on the
36//! tick it changed, then dropped once no session under it remains.
37//!
38//! Per-snapshot semantics:
39//!
40//! * `announced` / `announced_closed`: cumulative count of broadcast
41//!   announce/unannounce events on this `(tier, role)`. Bumped on every
42//!   `publisher()` / `subscriber()` guard creation and drop.
43//! * `announced_bytes`: cumulative broadcast-name length summed over each
44//!   announce and unannounce of this broadcast (the name, not the encoded
45//!   message size, so hop/framing overhead isn't charged, and the count is
46//!   the same across protocol versions). Recorded keyed by path via
47//!   [`BroadcastStats::publisher_announced_bytes`] /
48//!   [`BroadcastStats::subscriber_announced_bytes`], independent of the
49//!   announce lifetime guard, so filtered/reflected/unmatched control flows
50//!   still count. Kept separate from the `bytes` payload counter.
51//! * `broadcasts` / `broadcasts_closed`: per-(broadcast, session)
52//!   subscription sentinel. The first active subscription a peer session
53//!   opens for a broadcast bumps `broadcasts`; the last one it closes bumps
54//!   `broadcasts_closed`. Summed across sessions, `broadcasts -
55//!   broadcasts_closed` is the number of distinct sessions currently
56//!   subscribed to the broadcast (i.e. viewers on the egress side). Driven
57//!   by [`SessionBroadcasts`]; use `announced` if you want all broadcasts
58//!   ever seen.
59//! * `subscriptions` / `subscriptions_closed`: cumulative count of
60//!   track-level subscription guards opened/dropped.
61//! * `bytes` / `frames` / `groups`: cumulative payload counters bumped from
62//!   the session loops (both lite and IETF).
63//! * `sessions` / `sessions_closed` (session tracks only): cumulative count
64//!   of sessions connected/disconnected under an auth root on this tier.
65//!   Driven by [`StatsHandle::session`].
66//!
67//! Counters are strictly monotonic (only `fetch_add`); a counter going
68//! backwards across snapshots means the underlying entry was garbage
69//! collected and re-created. Downstream consumers should treat decreases
70//! as a fresh session segment, summing across resets when computing
71//! lifetime totals.
72//!
73//! A caller hands each session a tier-scoped [`StatsHandle`] (built from the
74//! single shared [`Stats`] via [`Stats::tier`]) which determines which counter
75//! set its bumps land in. Multiple relays in the same cluster origin can
76//! coexist by giving each one a distinct `<node>` suffix on the advertised
77//! path. The suffix itself may be multi-segment (e.g. `sjc/1`, `sjc/2`) so a
78//! region with multiple hosts can nest under a shared region key without
79//! colliding.
80//!
81//! # Disabled stats
82//!
83//! A [`StatsConfig`] with no origin (the default) builds a no-op aggregator:
84//! all counter bumps are silently dropped, no snapshot task spawns, and no
85//! broadcast is published. [`Stats::default`] / [`StatsHandle::default`]
86//! return one, so call sites can hold a [`StatsHandle`] unconditionally
87//! instead of threading an `Option`.
88//!
89//! # Lifecycle
90//!
91//! When the config has an origin, [`Stats::new`] spawns the snapshot task
92//! immediately, publishes the stats broadcast, and ticks at the configured
93//! interval, writing a frame per (tier, role) track. The broadcast stays
94//! announced for the lifetime of the [`Stats`] aggregator, even while idle
95//! (frames just go to `{}`). The task exits when the last [`Stats`] clone is
96//! dropped (the task holds only a `Weak` to the shared state).
97//!
98//! # Idle frame skipping
99//!
100//! On each tick the task compares the just-built per-(tier, role) JSON payload
101//! against the last one it emitted and writes a frame only when something
102//! changed. New subscribers still pick up a baseline immediately because
103//! track-latest semantics retain the most recent emitted frame.
104//!
105//! # Snapshot atomicity
106//!
107//! Each [`Counters`] snapshot reads `*_closed` atomics (with `Acquire`)
108//! before their open counterparts (with `Relaxed`). The matching close
109//! bumps in the RAII guards' `Drop` impls use `Release`. With this
110//! pairing the snapshot always satisfies `open >= closed` even on
111//! weakly-ordered architectures (ARM, POWER): the `Acquire` load of
112//! close synchronizes-with the `Release` bump that produced the
113//! observed value, making every write that happened-before that close
114//! (including the matching open bump on whichever thread opened the
115//! guard) visible to the snapshot thread. Open / payload counters can
116//! then stay `Relaxed` because the visibility comes for free through
117//! the close pairing. The cost is a slight upward bias on the open
118//! counts when a bump lands between the two loads, which never produces
119//! a logically impossible (`closed > open`) snapshot for downstream.
120//!
121//! # Cycles
122//!
123//! Calling [`StatsHandle::broadcast`] for a path under the configured
124//! top-level prefix returns an empty handle whose bumps no-op. This breaks
125//! the feedback loop where serving a `<top-prefix>/...` broadcast would
126//! itself generate more stats traffic.
127
128use std::{
129	collections::{BTreeMap, HashMap, HashSet},
130	sync::{
131		Arc, Weak,
132		atomic::{AtomicU64, Ordering},
133	},
134	time::Duration,
135};
136
137use serde::Serialize;
138use web_async::{Lock, spawn};
139
140use crate::{AsPath, Broadcast, OriginProducer, Path, PathOwned, Track, TrackProducer};
141
142/// Cumulative atomic counters for a single `(tier, role)` on a broadcast.
143///
144/// Every field is bumped from a RAII guard: the open counters on construction
145/// and their `_closed` counterparts on drop. `broadcasts` / `broadcasts_closed`
146/// are the per-(broadcast, session) subscription sentinel driven by
147/// [`SessionBroadcasts`] (the first active subscription a session opens for the
148/// broadcast bumps `broadcasts`, the last to close bumps `broadcasts_closed`),
149/// so summed across sessions `broadcasts - broadcasts_closed` is the count of
150/// distinct sessions currently subscribed.
151#[derive(Default, Debug)]
152#[non_exhaustive]
153pub struct Counters {
154	pub announced: AtomicU64,
155	pub announced_closed: AtomicU64,
156	/// Cumulative broadcast-name length summed over each announce and unannounce
157	/// of this broadcast. Counts the name, not the encoded message size, so it
158	/// doesn't penalize the broadcast for hop/framing overhead. Kept separate
159	/// from `bytes`, which is media payload.
160	pub announced_bytes: AtomicU64,
161	pub subscriptions: AtomicU64,
162	pub subscriptions_closed: AtomicU64,
163	pub broadcasts: AtomicU64,
164	pub broadcasts_closed: AtomicU64,
165	pub bytes: AtomicU64,
166	pub frames: AtomicU64,
167	pub groups: AtomicU64,
168}
169
170impl Counters {
171	/// Read all atomics into a `RawCounts`. Closed counters are read with
172	/// `Acquire` ordering before their open counterparts so the snapshot
173	/// always satisfies `open >= closed`; see the module-level "Snapshot
174	/// atomicity" note. Open / payload counters stay `Relaxed`: the
175	/// Acquire on close synchronizes-with the matching Release on the
176	/// close bump, which transitively makes all earlier writes (including
177	/// the prior open bump) visible to this thread.
178	fn snapshot(&self) -> RawCounts {
179		let announced_closed = self.announced_closed.load(Ordering::Acquire);
180		let subscriptions_closed = self.subscriptions_closed.load(Ordering::Acquire);
181		let broadcasts_closed = self.broadcasts_closed.load(Ordering::Acquire);
182		let announced = self.announced.load(Ordering::Relaxed);
183		let announced_bytes = self.announced_bytes.load(Ordering::Relaxed);
184		let subscriptions = self.subscriptions.load(Ordering::Relaxed);
185		let broadcasts = self.broadcasts.load(Ordering::Relaxed);
186		let bytes = self.bytes.load(Ordering::Relaxed);
187		let frames = self.frames.load(Ordering::Relaxed);
188		let groups = self.groups.load(Ordering::Relaxed);
189		RawCounts {
190			announced,
191			announced_closed,
192			announced_bytes,
193			broadcasts,
194			broadcasts_closed,
195			subscriptions,
196			subscriptions_closed,
197			bytes,
198			frames,
199			groups,
200		}
201	}
202}
203
204/// Per-(tier, root) session gauge. One of these is shared (via `Arc`) by every
205/// [`SessionStats`] guard for the same auth root on the same tier: `sessions`
206/// bumps on connect, `sessions_closed` on disconnect.
207#[derive(Default, Debug)]
208struct SessionCounters {
209	sessions: AtomicU64,
210	sessions_closed: AtomicU64,
211}
212
213impl SessionCounters {
214	/// Read `(sessions, sessions_closed)`. Closed is loaded with `Acquire`
215	/// before open with `Relaxed`, the same pairing as [`Counters::snapshot`],
216	/// so the readout never shows `closed > open`.
217	fn snapshot(&self) -> (u64, u64) {
218		let closed = self.sessions_closed.load(Ordering::Acquire);
219		let open = self.sessions.load(Ordering::Relaxed);
220		(open, closed)
221	}
222}
223
224/// Raw counter readout. Intermediate type that doesn't escape this module.
225#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
226struct RawCounts {
227	announced: u64,
228	announced_closed: u64,
229	announced_bytes: u64,
230	broadcasts: u64,
231	broadcasts_closed: u64,
232	subscriptions: u64,
233	subscriptions_closed: u64,
234	bytes: u64,
235	frames: u64,
236	groups: u64,
237}
238
239/// Distinguishes traffic classes so a single [`Stats`] can record
240/// customer-facing and cluster-peer traffic separately. Each tracked
241/// broadcast keeps per-tier [`Counters`] on both its publisher and
242/// subscriber sides.
243#[derive(Copy, Clone, Debug, PartialEq, Eq)]
244pub enum Tier {
245	External,
246	Internal,
247}
248
249impl Tier {
250	fn idx(self) -> usize {
251		match self {
252			Tier::External => 0,
253			Tier::Internal => 1,
254		}
255	}
256}
257
258/// Settings for a [`Stats`] aggregator. Construct with [`StatsConfig::new`]
259/// and chain the `with_*` setters (e.g.
260/// `StatsConfig::new().with_origin(origin).with_prefix(".foo")`), then hand it
261/// to [`Stats::new`].
262///
263/// With no origin set the resulting aggregator is a no-op: bumps are dropped
264/// and no task spawns. Call [`StatsConfig::with_origin`] to publish.
265///
266/// Distinct from the relay's clap-derived `StatsConfig`, which holds the raw
267/// CLI/TOML knobs and resolves into one of these.
268///
269/// `#[non_exhaustive]` so new knobs can land without breaking call sites; build
270/// via [`StatsConfig::new`] rather than a struct literal.
271#[derive(Clone)]
272#[non_exhaustive]
273pub struct StatsConfig {
274	/// Origin that receives the stats broadcast's `publish_broadcast` calls.
275	/// When `None`, [`Stats::new`] spawns no task and publishes nothing.
276	pub origin: Option<OriginProducer>,
277	/// Top-level path stats are published under (default `.stats`). The full
278	/// advertised path is `<prefix>/node/<node>` (or `<prefix>/node` when
279	/// `node` is unset).
280	pub prefix: PathOwned,
281	/// Node suffix that disambiguates broadcasts from different relays sharing a
282	/// cluster origin. Set this on every node in multi-relay deployments. May be
283	/// multi-segment (e.g. `sjc/1`, `sjc/2`) so a region with multiple hosts can
284	/// nest under a shared region key. An empty path is treated as unset.
285	/// Default none.
286	pub node: Option<PathOwned>,
287	/// How long the snapshot task waits between publishes. Default 1s.
288	pub interval: Duration,
289	/// How many leading path segments of each broadcast to use as a grouping
290	/// key, splitting the output into one broadcast per group at
291	/// `<prefix>/<group>/node/<node>`. Default `0`: a single
292	/// `<prefix>/node/<node>` broadcast carrying every path (the historical
293	/// behavior). `1` buckets by the first segment (e.g. a per-tenant broadcast),
294	/// so a consumer can announce-scope to just that group instead of slurping
295	/// every node's full stats. A group broadcast is announced while its group
296	/// has live traffic and unannounced once it drains; at depth `0` the single
297	/// broadcast stays announced for the aggregator's life even while idle.
298	pub depth: usize,
299}
300
301impl StatsConfig {
302	/// A config with default settings: no origin (no-op), `.stats` prefix, 1s
303	/// snapshot interval, and no node suffix. Call [`Self::with_origin`] to
304	/// actually publish.
305	pub fn new() -> Self {
306		Self {
307			origin: None,
308			prefix: PathOwned::from(".stats"),
309			node: None,
310			interval: Duration::from_secs(1),
311			depth: 0,
312		}
313	}
314
315	/// Set the origin to publish the stats broadcast on. Without this the
316	/// aggregator is a no-op.
317	pub fn with_origin(mut self, origin: impl Into<Option<OriginProducer>>) -> Self {
318		self.origin = origin.into();
319		self
320	}
321
322	/// Override the top-level prefix (default `.stats`).
323	pub fn with_prefix(mut self, prefix: impl Into<PathOwned>) -> Self {
324		self.prefix = prefix.into();
325		self
326	}
327
328	/// Override the snapshot interval (default 1s).
329	pub fn with_interval(mut self, interval: Duration) -> Self {
330		self.interval = interval;
331		self
332	}
333
334	/// Set the node suffix (default none). An empty path is treated as unset.
335	pub fn with_node(mut self, node: impl Into<Option<PathOwned>>) -> Self {
336		self.node = node.into();
337		self
338	}
339
340	/// Set the grouping depth (default 0, a single broadcast). See
341	/// [`Self::depth`].
342	pub fn with_depth(mut self, depth: usize) -> Self {
343		self.depth = depth;
344		self
345	}
346}
347
348impl Default for StatsConfig {
349	fn default() -> Self {
350		Self::new()
351	}
352}
353
354/// Top-level stats aggregator. Cheap to clone (`Arc` inside for the shared
355/// runtime state). One instance per relay; sessions get tier-scoped handles via
356/// [`Stats::tier`]. Build it from a [`StatsConfig`] via [`Stats::new`].
357#[derive(Clone)]
358pub struct Stats {
359	prefix: PathOwned,
360	/// `None` for a no-op aggregator (config had no origin): bumps are
361	/// dropped and no task was spawned.
362	shared: Option<Arc<StatsShared>>,
363}
364
365/// Runtime state shared by every clone of a [`Stats`] and held by the
366/// snapshot task through a `Weak`. Only allocated when an origin is set.
367struct StatsShared {
368	origin: OriginProducer,
369	entries: Lock<HashMap<PathOwned, Arc<BroadcastEntry>>>,
370	/// Connected-session gauges keyed by auth root, one map per tier (indexed
371	/// by `Tier::idx`). Independent of any broadcast; surfaced on the session
372	/// tracks.
373	sessions: [Lock<HashMap<PathOwned, Arc<SessionCounters>>>; 2],
374}
375
376/// Per-broadcast counters split by side then tier. The two side fields are
377/// named explicitly (rather than indexed by some `Role` enum) because the
378/// bump-path call sites always know which side they're on at compile time;
379/// only the tier varies dynamically with the session.
380struct BroadcastEntry {
381	publisher: [Counters; 2],
382	subscriber: [Counters; 2],
383}
384
385impl BroadcastEntry {
386	fn new() -> Self {
387		Self {
388			publisher: Default::default(),
389			subscriber: Default::default(),
390		}
391	}
392}
393
394/// Per-(entry, slot) state owned by the snapshot task. The snapshot task
395/// is single-threaded so this needs no atomics; we keep one of these per
396/// `(path, side, tier)` in a task-local map, mirroring the structure of
397/// [`BroadcastEntry`].
398#[derive(Default)]
399struct SlotState {
400	/// Last `Snapshot` we wrote to the frame for this slot, used to detect
401	/// changes that warrant re-emission.
402	prev_emitted: Option<Snapshot>,
403}
404
405/// Snapshot-task-local mirror of [`BroadcastEntry`]: per-side, per-tier
406/// `SlotState`. Same field layout so iteration in the snapshot loop is
407/// trivially parallel between the two.
408#[derive(Default)]
409struct EntrySnapState {
410	publisher: [SlotState; 2],
411	subscriber: [SlotState; 2],
412}
413
414impl EntrySnapState {
415	/// Iterate the four `(track_name, counters, slot_state)` slots in the
416	/// fixed order matching `TRACK_ORDER`.
417	fn zip_slots<'a>(&'a mut self, entry: &'a BroadcastEntry) -> [(&'static str, &'a Counters, &'a mut SlotState); 4] {
418		let [pub_ext_state, pub_int_state] = &mut self.publisher;
419		let [sub_ext_state, sub_int_state] = &mut self.subscriber;
420		[
421			("publisher.json", &entry.publisher[Tier::External.idx()], pub_ext_state),
422			(
423				"subscriber.json",
424				&entry.subscriber[Tier::External.idx()],
425				sub_ext_state,
426			),
427			(
428				"internal/publisher.json",
429				&entry.publisher[Tier::Internal.idx()],
430				pub_int_state,
431			),
432			(
433				"internal/subscriber.json",
434				&entry.subscriber[Tier::Internal.idx()],
435				sub_int_state,
436			),
437		]
438	}
439}
440
441/// Number of `(side, tier)` slots, matching the four tracks per stats
442/// broadcast.
443const NUM_SLOTS: usize = 4;
444
445/// Track names in the same order [`EntrySnapState::zip_slots`] returns
446/// them. Used to construct the per-broadcast track set up front.
447const TRACK_ORDER: [&str; NUM_SLOTS] = [
448	"publisher.json",
449	"subscriber.json",
450	"internal/publisher.json",
451	"internal/subscriber.json",
452];
453
454/// Session track names, indexed by [`Tier::idx`]: external first, internal
455/// second.
456const SESSION_TRACK_ORDER: [&str; 2] = ["sessions.json", "internal/sessions.json"];
457
458impl Stats {
459	/// Build a stats aggregator from `config`.
460	///
461	/// When `config` has an origin, this spawns the snapshot task immediately
462	/// and publishes the stats broadcast; the task runs until the last [`Stats`]
463	/// clone is dropped. With no origin the aggregator is a no-op (bumps are
464	/// dropped, nothing is published) and no task spawns, so it's safe to build
465	/// outside an async runtime.
466	pub fn new(config: StatsConfig) -> Self {
467		let StatsConfig {
468			origin,
469			prefix,
470			node,
471			interval,
472			depth,
473		} = config;
474		// An empty path after normalization is indistinguishable from "no node
475		// set"; collapse it so downstream code only sees a single representation.
476		// We do this here (not in `with_node`) so a directly-assigned
477		// `config.node` is normalized too.
478		let node = node.filter(|p| !p.is_empty());
479
480		let shared = origin.map(|origin| {
481			let shared = Arc::new(StatsShared {
482				origin,
483				entries: Lock::default(),
484				sessions: Default::default(),
485			});
486			spawn(run_publisher(
487				Arc::downgrade(&shared),
488				prefix.clone(),
489				node.clone(),
490				depth,
491				interval,
492			));
493			shared
494		});
495
496		Self { prefix, shared }
497	}
498
499	/// Returns the configured top-level prefix.
500	pub fn prefix(&self) -> &Path<'static> {
501		&self.prefix
502	}
503
504	/// The shared state, panicking for a no-op aggregator. Tests build with an
505	/// origin so this is always present.
506	#[cfg(test)]
507	fn shared(&self) -> &Arc<StatsShared> {
508		self.shared.as_ref().expect("enabled stats aggregator")
509	}
510
511	/// Returns a tier-scoped handle. Bumps through this handle land in the
512	/// tier's counters.
513	pub fn tier(&self, tier: Tier) -> StatsHandle {
514		StatsHandle {
515			stats: self.clone(),
516			tier,
517		}
518	}
519
520	fn entry(&self, path: impl AsPath) -> Option<Arc<BroadcastEntry>> {
521		// No-op aggregator (no origin) never allocates state.
522		let shared = self.shared.as_ref()?;
523		let path = path.as_path();
524		// Skip our own stats broadcasts (and any sibling category under the
525		// same prefix) so serving a stats broadcast doesn't generate more
526		// stats.
527		if path.has_prefix(&self.prefix) {
528			return None;
529		}
530		let owned = path.to_owned();
531		let mut entries = shared.entries.lock();
532		Some(
533			entries
534				.entry(owned)
535				.or_insert_with(|| Arc::new(BroadcastEntry::new()))
536				.clone(),
537		)
538	}
539
540	/// Get-or-create the session gauge for `root` on `tier`. `None` for a no-op
541	/// aggregator. Unlike [`Self::entry`], roots are auth scopes (never under
542	/// the stats prefix), so no cycle-breaking filter is needed.
543	fn session_counters(&self, tier: Tier, root: impl AsPath) -> Option<Arc<SessionCounters>> {
544		let shared = self.shared.as_ref()?;
545		let owned = root.as_path().to_owned();
546		let mut sessions = shared.sessions[tier.idx()].lock();
547		Some(sessions.entry(owned).or_default().clone())
548	}
549}
550
551impl Default for Stats {
552	fn default() -> Self {
553		Self::new(StatsConfig::new())
554	}
555}
556
557/// Tier-scoped wrapper around [`Stats`]. What [`crate::Client::with_stats`] and
558/// [`crate::Server::with_stats`] accept. Cheap to clone.
559#[derive(Clone)]
560pub struct StatsHandle {
561	stats: Stats,
562	tier: Tier,
563}
564
565impl StatsHandle {
566	/// The aggregator this handle is tied to.
567	pub fn parent(&self) -> &Stats {
568		&self.stats
569	}
570
571	/// The tier this handle bumps into.
572	pub fn tier(&self) -> Tier {
573		self.tier
574	}
575
576	/// Returns a per-broadcast handle scoped to this tier.
577	///
578	/// Paths under the aggregator's configured `prefix` return an empty handle
579	/// whose bumps are no-ops. This keeps stats traffic from feeding back into
580	/// the aggregator.
581	pub fn broadcast(&self, path: impl AsPath) -> BroadcastStats {
582		BroadcastStats {
583			entry: self.stats.entry(path),
584			tier: self.tier,
585		}
586	}
587
588	/// Per-session egress (publisher) broadcast-subscription tracker. Construct
589	/// one per session and call [`SessionBroadcasts::subscribe`] for each
590	/// downstream subscription so `broadcasts - broadcasts_closed` counts the
591	/// distinct sessions watching each broadcast.
592	pub fn publisher_broadcasts(&self) -> SessionBroadcasts {
593		SessionBroadcasts::new(self.stats.clone(), self.tier, Side::Publisher)
594	}
595
596	/// Per-session ingress (subscriber) counterpart to
597	/// [`Self::publisher_broadcasts`].
598	pub fn subscriber_broadcasts(&self) -> SessionBroadcasts {
599		SessionBroadcasts::new(self.stats.clone(), self.tier, Side::Subscriber)
600	}
601
602	/// Record a connected session authenticated under `root` on this tier. Hold
603	/// the returned guard for the session's lifetime; dropping it bumps
604	/// `sessions_closed`. Counts presence regardless of any data flow, so a
605	/// session that merely connects is still billable. Surfaced on the session
606	/// track for this tier, keyed by `root`.
607	pub fn session(&self, root: impl AsPath) -> SessionStats {
608		SessionStats::new(self.stats.session_counters(self.tier, root))
609	}
610}
611
612impl Default for StatsHandle {
613	/// A no-op handle backed by a [`Stats::default`] aggregator.
614	fn default() -> Self {
615		Stats::default().tier(Tier::External)
616	}
617}
618
619/// A per-broadcast, tier-scoped handle. Cheap to clone.
620///
621/// Open a broadcast-lifetime guard with [`Self::publisher`] / [`Self::subscriber`],
622/// or skip straight to a track guard with [`Self::publisher_track`] /
623/// [`Self::subscriber_track`] when the broadcast's lifetime is tracked
624/// elsewhere.
625#[derive(Clone)]
626pub struct BroadcastStats {
627	entry: Option<Arc<BroadcastEntry>>,
628	tier: Tier,
629}
630
631impl BroadcastStats {
632	/// True if this handle has no underlying entry (path was under the
633	/// aggregator's own prefix, or stats are disabled). All bumps through an
634	/// empty handle are no-ops.
635	pub fn is_empty(&self) -> bool {
636		self.entry.is_none()
637	}
638
639	/// Open a broadcast-lifetime guard for the publisher (egress) role.
640	/// Bumps `announced` on construction and `announced_closed` on drop.
641	/// (The `broadcasts` sentinel is driven separately by
642	/// [`SessionBroadcasts`]; see the module docs.)
643	pub fn publisher(&self) -> PublisherStats {
644		if let Some(entry) = &self.entry {
645			entry.publisher[self.tier.idx()]
646				.announced
647				.fetch_add(1, Ordering::Relaxed);
648		}
649		PublisherStats {
650			entry: self.entry.clone(),
651			tier: self.tier,
652		}
653	}
654
655	/// Open a broadcast-lifetime guard for the subscriber (ingress) role.
656	/// Bumps `announced` on construction and `announced_closed` on drop.
657	/// (The `broadcasts` sentinel is driven separately by
658	/// [`SessionBroadcasts`]; see the module docs.)
659	pub fn subscriber(&self) -> SubscriberStats {
660		if let Some(entry) = &self.entry {
661			entry.subscriber[self.tier.idx()]
662				.announced
663				.fetch_add(1, Ordering::Relaxed);
664		}
665		SubscriberStats {
666			entry: self.entry.clone(),
667			tier: self.tier,
668		}
669	}
670
671	/// Open a publisher-track guard.
672	///
673	/// `_name` is unused; counters are per-broadcast only. The track name
674	/// parameter is kept for symmetry with the rest of moq-net so callers
675	/// don't have to thread an `Option<&str>` through subscribe sites.
676	pub fn publisher_track(&self, _name: &str) -> PublisherTrack {
677		if let Some(entry) = &self.entry {
678			entry.publisher[self.tier.idx()]
679				.subscriptions
680				.fetch_add(1, Ordering::Relaxed);
681		}
682		PublisherTrack {
683			entry: self.entry.clone(),
684			tier: self.tier,
685		}
686	}
687
688	/// Record `n` announce-control bytes (the broadcast name length) for one
689	/// publisher-side announce/unannounce, independent of any lifetime guard.
690	/// Recording is keyed by broadcast path, so it still captures messages
691	/// whose matching guard was skipped, reflected, or already dropped (e.g.
692	/// an unannounce whose announce was filtered out). Bumps `announced_bytes`;
693	/// distinct from [`PublisherTrack::bytes`], which counts media payload.
694	pub fn publisher_announced_bytes(&self, n: u64) {
695		if let Some(entry) = &self.entry {
696			entry.publisher[self.tier.idx()]
697				.announced_bytes
698				.fetch_add(n, Ordering::Relaxed);
699		}
700	}
701
702	/// Subscriber-side counterpart to [`Self::publisher_announced_bytes`].
703	pub fn subscriber_announced_bytes(&self, n: u64) {
704		if let Some(entry) = &self.entry {
705			entry.subscriber[self.tier.idx()]
706				.announced_bytes
707				.fetch_add(n, Ordering::Relaxed);
708		}
709	}
710
711	/// Subscriber-side counterpart to [`Self::publisher_track`].
712	pub fn subscriber_track(&self, _name: &str) -> SubscriberTrack {
713		if let Some(entry) = &self.entry {
714			entry.subscriber[self.tier.idx()]
715				.subscriptions
716				.fetch_add(1, Ordering::Relaxed);
717		}
718		SubscriberTrack {
719			entry: self.entry.clone(),
720			tier: self.tier,
721		}
722	}
723}
724
725/// Which side of a [`BroadcastEntry`] a [`SessionBroadcasts`] bumps.
726#[derive(Copy, Clone)]
727enum Side {
728	Publisher,
729	Subscriber,
730}
731
732impl Side {
733	fn counters(self, entry: &BroadcastEntry, tier: Tier) -> &Counters {
734		match self {
735			Side::Publisher => &entry.publisher[tier.idx()],
736			Side::Subscriber => &entry.subscriber[tier.idx()],
737		}
738	}
739}
740
741/// Per-session tracker that turns a peer session's per-broadcast subscription
742/// lifecycle into `broadcasts` / `broadcasts_closed` bumps.
743///
744/// Hold one per session (and side). Call [`Self::subscribe`] for every
745/// subscription the session opens and keep the returned [`BroadcastSubscription`]
746/// alive for that subscription's lifetime. The guard refcounts subscriptions per
747/// broadcast for this session, so the session's *first* subscription to a
748/// broadcast bumps `broadcasts` and its *last* to drop bumps `broadcasts_closed`.
749/// Summed across sessions, `broadcasts - broadcasts_closed` is the number of
750/// distinct sessions currently subscribed to the broadcast (viewers on the
751/// egress side).
752///
753/// Cheap to clone; clones share the same per-broadcast refcounts (so a single
754/// logical session that clones its handle still counts as one).
755#[derive(Clone)]
756pub struct SessionBroadcasts {
757	stats: Stats,
758	tier: Tier,
759	side: Side,
760	counts: Arc<std::sync::Mutex<HashMap<PathOwned, u32>>>,
761}
762
763impl SessionBroadcasts {
764	fn new(stats: Stats, tier: Tier, side: Side) -> Self {
765		Self {
766			stats,
767			tier,
768			side,
769			counts: Arc::new(std::sync::Mutex::new(HashMap::new())),
770		}
771	}
772
773	/// Register one active subscription to `path` for this session. Hold the
774	/// returned guard for the subscription's lifetime; dropping it releases the
775	/// subscription (bumping `broadcasts_closed` when it was the session's last
776	/// for that broadcast).
777	pub fn subscribe(&self, path: impl AsPath) -> BroadcastSubscription {
778		let path = path.as_path().to_owned();
779		let entry = self.stats.entry(&path);
780		let first = {
781			let mut counts = self.counts.lock().expect("stats refcount poisoned");
782			let n = counts.entry(path.clone()).or_insert(0);
783			let first = *n == 0;
784			*n += 1;
785			first
786		};
787		if first {
788			if let Some(entry) = &entry {
789				self.side
790					.counters(entry, self.tier)
791					.broadcasts
792					.fetch_add(1, Ordering::Relaxed);
793			}
794		}
795		BroadcastSubscription {
796			entry,
797			tier: self.tier,
798			side: self.side,
799			counts: self.counts.clone(),
800			path,
801		}
802	}
803}
804
805/// RAII guard for one of a session's per-broadcast subscriptions.
806/// See [`SessionBroadcasts::subscribe`].
807#[must_use = "drop the guard to release the subscription"]
808pub struct BroadcastSubscription {
809	entry: Option<Arc<BroadcastEntry>>,
810	tier: Tier,
811	side: Side,
812	counts: Arc<std::sync::Mutex<HashMap<PathOwned, u32>>>,
813	path: PathOwned,
814}
815
816impl Drop for BroadcastSubscription {
817	fn drop(&mut self) {
818		let last = {
819			let mut counts = self.counts.lock().expect("stats refcount poisoned");
820			match counts.get_mut(&self.path) {
821				Some(n) => {
822					*n -= 1;
823					if *n == 0 {
824						counts.remove(&self.path);
825						true
826					} else {
827						false
828					}
829				}
830				None => false,
831			}
832		};
833		if last {
834			if let Some(entry) = &self.entry {
835				// Release pairs with the snapshot reader's Acquire load of
836				// `broadcasts_closed`; see `PublisherStats::drop`.
837				self.side
838					.counters(entry, self.tier)
839					.broadcasts_closed
840					.fetch_add(1, Ordering::Release);
841			}
842		}
843	}
844}
845
846/// RAII guard for a connected session, keyed by auth root and tier. Bumps
847/// `sessions` on construction and `sessions_closed` on drop. See
848/// [`StatsHandle::session`].
849#[must_use = "drop the guard to record the session as closed"]
850pub struct SessionStats {
851	/// `None` for a no-op aggregator; bumps are then dropped.
852	counters: Option<Arc<SessionCounters>>,
853}
854
855impl SessionStats {
856	fn new(counters: Option<Arc<SessionCounters>>) -> Self {
857		if let Some(counters) = &counters {
858			counters.sessions.fetch_add(1, Ordering::Relaxed);
859		}
860		Self { counters }
861	}
862}
863
864impl Drop for SessionStats {
865	fn drop(&mut self) {
866		if let Some(counters) = &self.counters {
867			// Release pairs with the snapshot reader's Acquire load of
868			// `sessions_closed`; see `PublisherStats::drop`.
869			counters.sessions_closed.fetch_add(1, Ordering::Release);
870		}
871	}
872}
873
874/// RAII broadcast guard for the publisher role. See [`BroadcastStats::publisher`].
875#[must_use = "drop the guard to record the broadcast as closed"]
876pub struct PublisherStats {
877	entry: Option<Arc<BroadcastEntry>>,
878	tier: Tier,
879}
880
881impl PublisherStats {
882	/// Open a track-subscription guard. Bumps `subscriptions` on construction
883	/// and `subscriptions_closed` on drop.
884	pub fn track(&self, name: &str) -> PublisherTrack {
885		BroadcastStats {
886			entry: self.entry.clone(),
887			tier: self.tier,
888		}
889		.publisher_track(name)
890	}
891}
892
893impl Drop for PublisherStats {
894	fn drop(&mut self) {
895		if let Some(entry) = &self.entry {
896			// Release pairs with the snapshot reader's Acquire load of
897			// `announced_closed`, propagating the open-bump from this
898			// guard's construction to whichever thread observes the close.
899			entry.publisher[self.tier.idx()]
900				.announced_closed
901				.fetch_add(1, Ordering::Release);
902		}
903	}
904}
905
906/// RAII broadcast guard for the subscriber role. See [`BroadcastStats::subscriber`].
907#[must_use = "drop the guard to record the broadcast as closed"]
908pub struct SubscriberStats {
909	entry: Option<Arc<BroadcastEntry>>,
910	tier: Tier,
911}
912
913impl SubscriberStats {
914	/// Open a track-subscription guard. Mirrors [`PublisherStats::track`].
915	pub fn track(&self, name: &str) -> SubscriberTrack {
916		BroadcastStats {
917			entry: self.entry.clone(),
918			tier: self.tier,
919		}
920		.subscriber_track(name)
921	}
922}
923
924impl Drop for SubscriberStats {
925	fn drop(&mut self) {
926		if let Some(entry) = &self.entry {
927			// See `PublisherStats::drop` for why this is Release.
928			entry.subscriber[self.tier.idx()]
929				.announced_closed
930				.fetch_add(1, Ordering::Release);
931		}
932	}
933}
934
935/// RAII subscription guard for the publisher role.
936#[must_use = "drop the guard to record the subscription as closed"]
937pub struct PublisherTrack {
938	entry: Option<Arc<BroadcastEntry>>,
939	tier: Tier,
940}
941
942impl PublisherTrack {
943	/// Bumps `frames` once.
944	pub fn frame(&self) {
945		if let Some(entry) = &self.entry {
946			entry.publisher[self.tier.idx()].frames.fetch_add(1, Ordering::Relaxed);
947		}
948	}
949
950	/// Bumps `bytes` by `n`.
951	pub fn bytes(&self, n: u64) {
952		if let Some(entry) = &self.entry {
953			entry.publisher[self.tier.idx()].bytes.fetch_add(n, Ordering::Relaxed);
954		}
955	}
956
957	/// Bumps `groups` once.
958	pub fn group(&self) {
959		if let Some(entry) = &self.entry {
960			entry.publisher[self.tier.idx()].groups.fetch_add(1, Ordering::Relaxed);
961		}
962	}
963}
964
965impl Drop for PublisherTrack {
966	fn drop(&mut self) {
967		if let Some(entry) = &self.entry {
968			// See `PublisherStats::drop` for why this is Release.
969			entry.publisher[self.tier.idx()]
970				.subscriptions_closed
971				.fetch_add(1, Ordering::Release);
972		}
973	}
974}
975
976/// RAII subscription guard for the subscriber role.
977#[must_use = "drop the guard to record the subscription as closed"]
978pub struct SubscriberTrack {
979	entry: Option<Arc<BroadcastEntry>>,
980	tier: Tier,
981}
982
983impl SubscriberTrack {
984	/// Bumps `frames` once.
985	pub fn frame(&self) {
986		if let Some(entry) = &self.entry {
987			entry.subscriber[self.tier.idx()].frames.fetch_add(1, Ordering::Relaxed);
988		}
989	}
990
991	/// Bumps `bytes` by `n`.
992	pub fn bytes(&self, n: u64) {
993		if let Some(entry) = &self.entry {
994			entry.subscriber[self.tier.idx()].bytes.fetch_add(n, Ordering::Relaxed);
995		}
996	}
997
998	/// Bumps `groups` once.
999	pub fn group(&self) {
1000		if let Some(entry) = &self.entry {
1001			entry.subscriber[self.tier.idx()].groups.fetch_add(1, Ordering::Relaxed);
1002		}
1003	}
1004}
1005
1006impl Drop for SubscriberTrack {
1007	fn drop(&mut self) {
1008		if let Some(entry) = &self.entry {
1009			// See `PublisherStats::drop` for why this is Release.
1010			entry.subscriber[self.tier.idx()]
1011				.subscriptions_closed
1012				.fetch_add(1, Ordering::Release);
1013		}
1014	}
1015}
1016
1017/// Per-tick work for a single `(side, tier)` slot: build the emitted
1018/// `Snapshot` from the raw counters, update the slot's `prev_emitted`, and
1019/// hand the snap to `emit` iff the slot is live or changed this tick.
1020fn process_slot(counters: &Counters, slot_state: &mut SlotState, mut emit: impl FnMut(Snapshot)) {
1021	let raw = counters.snapshot();
1022
1023	let snap = Snapshot {
1024		announced: raw.announced,
1025		announced_closed: raw.announced_closed,
1026		announced_bytes: raw.announced_bytes,
1027		broadcasts: raw.broadcasts,
1028		broadcasts_closed: raw.broadcasts_closed,
1029		subscriptions: raw.subscriptions,
1030		subscriptions_closed: raw.subscriptions_closed,
1031		bytes: raw.bytes,
1032		frames: raw.frames,
1033		groups: raw.groups,
1034	};
1035
1036	// A slot is live while any open counter still exceeds its `*_closed`
1037	// counterpart: a guard is held, so a subscription could begin at any
1038	// moment. Live slots are emitted every tick so a downstream "currently
1039	// active" view always sees the full set. Once every pair is equal no
1040	// traffic can flow and the entry is on its way out (the global GC drops
1041	// it as soon as the last guard releases its `Arc`).
1042	let live = snap.announced != snap.announced_closed
1043		|| snap.subscriptions != snap.subscriptions_closed
1044		|| snap.broadcasts != snap.broadcasts_closed;
1045
1046	// Include the entry whenever it's live OR its snapshot changed this
1047	// tick. Change-driven inclusion catches bumps since the previous tick
1048	// (incl. sub-tick flickers) and emits the final close snapshot on the
1049	// tick a slot transitions to fully closed.
1050	//
1051	// `None` (slot never emitted) is treated as the default Snapshot so a
1052	// first-tick all-zeros snap on an unused tier-side slot doesn't count
1053	// as a "change". Without this, every entry would surface in all four
1054	// tracks with zeros on the tick after creation even if only one slot
1055	// is actually in use.
1056	let prev_snap = slot_state.prev_emitted.unwrap_or_default();
1057	let changed = snap != prev_snap;
1058	if changed {
1059		slot_state.prev_emitted = Some(snap);
1060	}
1061	if live || changed {
1062		emit(snap);
1063	}
1064}
1065
1066/// Snapshot-task-local change-detection state for one session-track root,
1067/// mirroring [`SlotState`].
1068#[derive(Default)]
1069struct SessionSlotState {
1070	prev_emitted: Option<SessionSnapshot>,
1071}
1072
1073/// Per-tick work for one session-track root (a `(tier, root)` gauge): build the
1074/// snapshot, update `prev_emitted`, and emit iff a session is connected
1075/// (`sessions != sessions_closed`) or the snapshot changed this tick. Same
1076/// live-or-changed rule as [`process_slot`].
1077fn process_session_slot(
1078	counters: &SessionCounters,
1079	slot_state: &mut SessionSlotState,
1080	mut emit: impl FnMut(SessionSnapshot),
1081) {
1082	let (sessions, sessions_closed) = counters.snapshot();
1083	let snap = SessionSnapshot {
1084		sessions,
1085		sessions_closed,
1086	};
1087
1088	let live = sessions != sessions_closed;
1089	let prev_snap = slot_state.prev_emitted.unwrap_or_default();
1090	let changed = snap != prev_snap;
1091	if changed {
1092		slot_state.prev_emitted = Some(snap);
1093	}
1094	if live || changed {
1095		emit(snap);
1096	}
1097}
1098
1099/// Serialize `frame` and write it to `track` unless it's byte-identical to
1100/// `last` (idle-frame skipping). On success `last` is updated; on a serialize
1101/// or write error it's left untouched so the next tick retries.
1102fn flush_track<T: Serialize>(track: &mut TrackProducer, frame: &T, last: &mut Vec<u8>, name: &str) {
1103	let json = match serde_json::to_vec(frame) {
1104		Ok(b) => b,
1105		Err(err) => {
1106			tracing::debug!(?err, name, "stats: failed to serialize frame");
1107			return;
1108		}
1109	};
1110	if &json == last {
1111		return;
1112	}
1113	if let Err(err) = track.write_frame(json.clone()) {
1114		tracing::debug!(?err, name, "stats: failed to write frame");
1115		return;
1116	}
1117	*last = json;
1118}
1119
1120/// Publishes the stats broadcast and writes a frame per tick. Spawned once by
1121/// [`Stats::new`] when an origin is set; runs until every [`Stats`] clone is
1122/// dropped (`weak.upgrade()` returns `None`).
1123/// Per-group publisher: one announced `<prefix>/<group>/node/<node>` broadcast
1124/// plus the change-detection state for its six tracks. Created when a group
1125/// first has traffic and dropped (unannouncing the broadcast) once it drains.
1126struct GroupPublisher {
1127	// Held to keep the broadcast announced; dropping it unannounces the group.
1128	// Never read: the track handles below carry the writes.
1129	_broadcast: crate::BroadcastProducer,
1130	tracks: Vec<TrackProducer>,
1131	session_tracks: Vec<TrackProducer>,
1132	/// Per-path snapshot state (the diff source for change detection) for the
1133	/// entries in this group.
1134	local: HashMap<PathOwned, EntrySnapState>,
1135	last_payload: [Vec<u8>; NUM_SLOTS],
1136	/// Per-tier, per-root snapshot state for the session tracks.
1137	session_local: [HashMap<PathOwned, SessionSlotState>; 2],
1138	session_last_payload: [Vec<u8>; 2],
1139}
1140
1141type GroupedEntries<'a> = HashMap<PathOwned, Vec<(&'a PathOwned, &'a Arc<BroadcastEntry>)>>;
1142type GroupedSessions<'a> = HashMap<PathOwned, Vec<(&'a PathOwned, &'a Arc<SessionCounters>)>>;
1143
1144impl GroupPublisher {
1145	/// Build + publish the broadcast for `group` on `origin`. Returns `None`
1146	/// (with a warning) if track creation or the publish is rejected, so one bad
1147	/// group doesn't tear down the whole aggregator.
1148	fn create(origin: &OriginProducer, prefix: &Path, group: &Path, node: Option<&str>) -> Option<Self> {
1149		let mut broadcast = Broadcast::new().produce();
1150
1151		// Create the four per-broadcast tracks and the two session tracks up front.
1152		let create = |broadcast: &mut crate::BroadcastProducer, name: &str| match broadcast.create_track(Track {
1153			name: name.into(),
1154			priority: 0,
1155		}) {
1156			Ok(t) => Some(t),
1157			Err(err) => {
1158				tracing::warn!(?err, name, "stats: failed to create track");
1159				None
1160			}
1161		};
1162
1163		let mut tracks: Vec<TrackProducer> = Vec::with_capacity(NUM_SLOTS);
1164		for name in TRACK_ORDER {
1165			tracks.push(create(&mut broadcast, name)?);
1166		}
1167		let mut session_tracks: Vec<TrackProducer> = Vec::with_capacity(SESSION_TRACK_ORDER.len());
1168		for name in SESSION_TRACK_ORDER {
1169			session_tracks.push(create(&mut broadcast, name)?);
1170		}
1171
1172		let advertised = advertised_path(prefix, group, node);
1173		if !origin.publish_broadcast(&advertised, broadcast.consume()) {
1174			tracing::warn!(advertised = %advertised, "stats: origin rejected stats broadcast");
1175			return None;
1176		}
1177		tracing::debug!(advertised = %advertised, "stats: publishing broadcast");
1178
1179		Some(Self {
1180			_broadcast: broadcast,
1181			tracks,
1182			session_tracks,
1183			local: HashMap::new(),
1184			last_payload: Default::default(),
1185			session_local: Default::default(),
1186			session_last_payload: Default::default(),
1187		})
1188	}
1189}
1190
1191/// The grouping key for `path`: its first `depth` `/`-separated segments (or the
1192/// whole path if it has fewer). `depth == 0` yields the empty path, i.e. a
1193/// single group carrying every broadcast.
1194fn group_key(path: &str, depth: usize) -> PathOwned {
1195	if depth == 0 {
1196		return Path::empty().to_owned();
1197	}
1198	// Cut before the `depth`-th separator; fewer separators means take it all.
1199	let mut seen = 0;
1200	let mut end = path.len();
1201	for (i, b) in path.bytes().enumerate() {
1202		if b == b'/' {
1203			seen += 1;
1204			if seen == depth {
1205				end = i;
1206				break;
1207			}
1208		}
1209	}
1210	Path::new(&path[..end]).to_owned()
1211}
1212
1213async fn run_publisher(
1214	weak: Weak<StatsShared>,
1215	prefix: PathOwned,
1216	node: Option<PathOwned>,
1217	depth: usize,
1218	interval: Duration,
1219) {
1220	let node = node.as_ref().map(|p| p.as_str());
1221
1222	// One publisher per active group key. At depth 0 the sole (empty-key) group
1223	// is created eagerly and never dropped, preserving the historical "single
1224	// broadcast, announced for the aggregator's life even while idle" behavior;
1225	// at depth >= 1 group broadcasts come and go with their group's traffic.
1226	let mut groups: HashMap<PathOwned, GroupPublisher> = HashMap::new();
1227	if depth == 0 {
1228		let Some(shared) = weak.upgrade() else {
1229			return;
1230		};
1231		let Some(gp) = GroupPublisher::create(&shared.origin, &prefix, &Path::empty(), node) else {
1232			return;
1233		};
1234		groups.insert(Path::empty().to_owned(), gp);
1235		drop(shared);
1236	}
1237
1238	let mut ticker = web_async::time::interval(interval);
1239	ticker.set_missed_tick_behavior(web_async::time::MissedTickBehavior::Delay);
1240
1241	loop {
1242		ticker.tick().await;
1243
1244		let Some(shared) = weak.upgrade() else {
1245			return;
1246		};
1247
1248		// Snapshot the global maps under their locks, then release so the
1249		// change-detection + flush pass runs lock-free.
1250		let entries: Vec<(PathOwned, Arc<BroadcastEntry>)> = {
1251			let map = shared.entries.lock();
1252			map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
1253		};
1254		let session_roots: [Vec<(PathOwned, Arc<SessionCounters>)>; 2] = [
1255			{
1256				let map = shared.sessions[0].lock();
1257				map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
1258			},
1259			{
1260				let map = shared.sessions[1].lock();
1261				map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
1262			},
1263		];
1264
1265		// Bucket entries + session roots by group key. Values borrow the
1266		// snapshots above (no extra strong count), so the GC pass below still
1267		// sees `strong_count == 1` for drained entries once the snapshots drop.
1268		let mut entries_by_group: GroupedEntries<'_> = HashMap::new();
1269		for (path, entry) in &entries {
1270			entries_by_group
1271				.entry(group_key(path.as_str(), depth))
1272				.or_default()
1273				.push((path, entry));
1274		}
1275		let mut roots_by_group: [GroupedSessions<'_>; 2] = Default::default();
1276		for tier_idx in 0..2 {
1277			for (root, counters) in &session_roots[tier_idx] {
1278				roots_by_group[tier_idx]
1279					.entry(group_key(root.as_str(), depth))
1280					.or_default()
1281					.push((root, counters));
1282			}
1283		}
1284
1285		// The groups live this tick: any with an entry or a session root, plus
1286		// the always-on empty group at depth 0.
1287		let mut active: HashSet<PathOwned> = HashSet::new();
1288		active.extend(entries_by_group.keys().cloned());
1289		for group_roots in &roots_by_group {
1290			active.extend(group_roots.keys().cloned());
1291		}
1292		if depth == 0 {
1293			active.insert(Path::empty().to_owned());
1294		}
1295
1296		for group in &active {
1297			// Ensure a publisher exists (skip the group this tick if create fails).
1298			if !groups.contains_key(group) {
1299				let Some(gp) = GroupPublisher::create(&shared.origin, &prefix, group, node) else {
1300					continue;
1301				};
1302				groups.insert(group.clone(), gp);
1303			}
1304			let gp = groups.get_mut(group).expect("just inserted");
1305
1306			// Per-(tier, role) frames for this group's broadcast.
1307			let mut frames: [BTreeMap<String, Snapshot>; NUM_SLOTS] = Default::default();
1308			if let Some(group_entries) = entries_by_group.get(group) {
1309				for &(path, entry) in group_entries {
1310					let snap_state = gp.local.entry(path.clone()).or_default();
1311					for (i, (_track_name, counters, slot_state)) in snap_state.zip_slots(entry).into_iter().enumerate()
1312					{
1313						process_slot(counters, slot_state, |snap| {
1314							frames[i].insert(path.as_str().to_string(), snap);
1315						});
1316					}
1317				}
1318			}
1319			for (i, (frame, last)) in frames.iter().zip(gp.last_payload.iter_mut()).enumerate() {
1320				flush_track(&mut gp.tracks[i], frame, last, TRACK_ORDER[i]);
1321			}
1322
1323			// Session frames, one per tier.
1324			let mut session_frames: [BTreeMap<String, SessionSnapshot>; 2] = Default::default();
1325			for tier_idx in 0..2 {
1326				if let Some(group_roots) = roots_by_group[tier_idx].get(group) {
1327					for &(root, counters) in group_roots {
1328						let state = gp.session_local[tier_idx].entry(root.clone()).or_default();
1329						process_session_slot(counters, state, |snap| {
1330							session_frames[tier_idx].insert(root.as_str().to_string(), snap);
1331						});
1332					}
1333				}
1334			}
1335			for (i, (frame, last)) in session_frames
1336				.iter()
1337				.zip(gp.session_last_payload.iter_mut())
1338				.enumerate()
1339			{
1340				flush_track(&mut gp.session_tracks[i], frame, last, SESSION_TRACK_ORDER[i]);
1341			}
1342		}
1343
1344		// Release the snapshot clones before the GC pass so drained entries/roots
1345		// hit `strong_count == 1` (just the map's own `Arc`).
1346		drop(entries_by_group);
1347		drop(roots_by_group);
1348		drop(entries);
1349		drop(session_roots);
1350
1351		// GC global entries + roots whose last external guard has dropped, then
1352		// forget the matching per-group change-detection state. Each removed
1353		// entry's final snapshot was already emitted above. We can't key this on
1354		// the counters directly: a held but idle guard (all counters equal) must
1355		// stay so a later bump isn't lost on an orphaned `Arc`.
1356		{
1357			let mut map = shared.entries.lock();
1358			map.retain(|_, entry| Arc::strong_count(entry) > 1);
1359			for gp in groups.values_mut() {
1360				gp.local.retain(|path, _| map.contains_key(path));
1361			}
1362		}
1363		for tier_idx in 0..2 {
1364			let mut map = shared.sessions[tier_idx].lock();
1365			map.retain(|_, counters| Arc::strong_count(counters) > 1);
1366			for gp in groups.values_mut() {
1367				gp.session_local[tier_idx].retain(|root, _| map.contains_key(root));
1368			}
1369		}
1370
1371		// Drop drained groups, unannouncing their broadcasts. The depth-0 empty
1372		// group stays in `active`, so it's retained.
1373		groups.retain(|group, _| active.contains(group));
1374
1375		drop(shared);
1376	}
1377}
1378
1379/// What we emit for one entry on one tier-role track. Every field comes
1380/// straight from [`RawCounts`]; `broadcasts` / `broadcasts_closed` are the
1381/// per-(broadcast, session) subscription sentinel maintained by
1382/// [`SessionBroadcasts`].
1383#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
1384#[cfg_attr(test, derive(serde::Deserialize))]
1385struct Snapshot {
1386	announced: u64,
1387	announced_closed: u64,
1388	announced_bytes: u64,
1389	broadcasts: u64,
1390	broadcasts_closed: u64,
1391	subscriptions: u64,
1392	subscriptions_closed: u64,
1393	bytes: u64,
1394	frames: u64,
1395	groups: u64,
1396}
1397
1398/// What we emit for one root on a session track. `sessions - sessions_closed`
1399/// is the live session count for the root.
1400#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
1401#[cfg_attr(test, derive(serde::Deserialize))]
1402struct SessionSnapshot {
1403	sessions: u64,
1404	sessions_closed: u64,
1405}
1406
1407fn advertised_path(prefix: &Path, group: &Path, node: Option<&str>) -> PathOwned {
1408	// `<prefix>/<group>/node/<node>`. The `group` segment (empty at depth 0)
1409	// buckets the output into one broadcast per group; the fixed `node` category
1410	// leaves room for sibling categories (e.g. `<prefix>/<group>/cluster` for
1411	// relay-mesh stats) under the same prefix.
1412	let mut out = prefix.as_str().to_string();
1413	if !group.is_empty() {
1414		out.push('/');
1415		out.push_str(group.as_str());
1416	}
1417	out.push_str("/node");
1418	if let Some(node) = node {
1419		out.push('/');
1420		out.push_str(node);
1421	}
1422	PathOwned::from(out)
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427	use std::{collections::BTreeMap, sync::atomic::Ordering::Relaxed};
1428
1429	use crate::{Origin, Path};
1430
1431	use super::*;
1432
1433	fn test_stats(node: Option<&str>) -> (Stats, OriginProducer) {
1434		let origin = Origin::random().produce();
1435		let stats = Stats::new(
1436			StatsConfig::new()
1437				.with_origin(origin.clone())
1438				.with_node(node.map(|s| PathOwned::from(s.to_string()))),
1439		);
1440		(stats, origin)
1441	}
1442
1443	#[test]
1444	fn advertised_path_with_and_without_node() {
1445		let prefix = Path::new(".stats");
1446		let none = Path::empty();
1447		// Depth 0 (empty group) is byte-for-byte the historical layout.
1448		assert_eq!(advertised_path(&prefix, &none, Some("sjc")).as_str(), ".stats/node/sjc");
1449		assert_eq!(
1450			advertised_path(&prefix, &none, Some("sjc/1")).as_str(),
1451			".stats/node/sjc/1"
1452		);
1453		assert_eq!(advertised_path(&prefix, &none, None).as_str(), ".stats/node");
1454
1455		let prefix = Path::new("metrics");
1456		assert_eq!(
1457			advertised_path(&prefix, &none, Some("lon")).as_str(),
1458			"metrics/node/lon"
1459		);
1460
1461		// A non-empty group nests between the prefix and the `node` category.
1462		let prefix = Path::new(".stats");
1463		let group = Path::new("acme");
1464		assert_eq!(
1465			advertised_path(&prefix, &group, Some("sjc")).as_str(),
1466			".stats/acme/node/sjc"
1467		);
1468		assert_eq!(advertised_path(&prefix, &group, None).as_str(), ".stats/acme/node");
1469	}
1470
1471	#[test]
1472	fn group_key_takes_leading_segments() {
1473		// Depth 0: everything shares the empty group.
1474		assert_eq!(group_key("acme/foo/bar", 0).as_str(), "");
1475		assert_eq!(group_key("", 0).as_str(), "");
1476		// Depth 1: the first segment (the tenant/project).
1477		assert_eq!(group_key("acme/foo/bar", 1).as_str(), "acme");
1478		assert_eq!(group_key("acme", 1).as_str(), "acme");
1479		// Depth 2: the first two segments.
1480		assert_eq!(group_key("acme/foo/bar", 2).as_str(), "acme/foo");
1481		// Fewer segments than the depth: take the whole path.
1482		assert_eq!(group_key("acme", 2).as_str(), "acme");
1483	}
1484
1485	/// The advertised path normalizes a messy node suffix and drops an
1486	/// all-empty one. Observed through the announced path, since the task
1487	/// announces at construction.
1488	async fn announced_path_for_node(node: &str) -> String {
1489		let origin = Origin::random().produce();
1490		let _stats = Stats::new(
1491			StatsConfig::new()
1492				.with_origin(origin.clone())
1493				.with_node(PathOwned::from(node.to_string())),
1494		);
1495		let mut consumer = origin.consume();
1496		tokio::time::advance(Duration::from_millis(1)).await;
1497		let (path, _broadcast) = consumer.announced().await.expect("expected announce");
1498		path.as_str().to_string()
1499	}
1500
1501	#[tokio::test(start_paused = true)]
1502	async fn new_normalizes_and_drops_empty_node() {
1503		assert_eq!(announced_path_for_node("/sjc//1/").await, ".stats/node/sjc/1");
1504		assert_eq!(announced_path_for_node("///").await, ".stats/node");
1505	}
1506
1507	#[tokio::test(start_paused = true)]
1508	async fn per_broadcast_counters_isolated() {
1509		// Bumps on one broadcast must not leak into another.
1510		let (stats, _origin) = test_stats(Some("sjc"));
1511		let bs1 = stats.tier(Tier::External).broadcast("demo/bbb");
1512		let bs2 = stats.tier(Tier::External).broadcast("demo/ccc");
1513		let g1 = bs1.publisher().track("video");
1514		g1.bytes(100);
1515		let g2 = bs2.publisher().track("video");
1516		g2.bytes(7);
1517
1518		let entries = stats.shared().entries.lock();
1519		let e1 = entries.get(&PathOwned::from("demo/bbb")).expect("entry");
1520		let e2 = entries.get(&PathOwned::from("demo/ccc")).expect("entry");
1521		assert_eq!(e1.publisher[Tier::External.idx()].bytes.load(Relaxed), 100);
1522		assert_eq!(e2.publisher[Tier::External.idx()].bytes.load(Relaxed), 7);
1523	}
1524
1525	#[tokio::test(start_paused = true)]
1526	async fn external_and_internal_tiers_are_independent() {
1527		let (stats, _origin) = test_stats(Some("sjc"));
1528		let ext = stats.tier(Tier::External);
1529		let int = stats.tier(Tier::Internal);
1530
1531		let ext_track = ext.broadcast("demo/bbb").publisher().track("video");
1532		ext_track.bytes(100);
1533		let int_track = int.broadcast("demo/bbb").subscriber().track("audio");
1534		int_track.bytes(7);
1535
1536		let entries = stats.shared().entries.lock();
1537		let entry = entries.get(&PathOwned::from("demo/bbb")).expect("entry");
1538		assert_eq!(entry.publisher[Tier::External.idx()].bytes.load(Relaxed), 100);
1539		assert_eq!(entry.subscriber[Tier::External.idx()].bytes.load(Relaxed), 0);
1540		assert_eq!(entry.publisher[Tier::Internal.idx()].bytes.load(Relaxed), 0);
1541		assert_eq!(entry.subscriber[Tier::Internal.idx()].bytes.load(Relaxed), 7);
1542	}
1543
1544	#[tokio::test(start_paused = true)]
1545	async fn paths_under_prefix_are_no_op() {
1546		// Our own stats broadcasts (and any sibling category under the same
1547		// prefix) must not feed back into the aggregator.
1548		let (stats, _origin) = test_stats(Some("sjc"));
1549		let bs = stats.tier(Tier::External).broadcast(".stats/node/sjc");
1550		assert!(bs.is_empty());
1551		let p = bs.publisher();
1552		let track = p.track("video");
1553		track.bytes(100);
1554		drop(track);
1555		drop(p);
1556		assert!(stats.shared().entries.lock().is_empty());
1557	}
1558
1559	#[tokio::test(start_paused = true)]
1560	async fn disabled_stats_are_noop() {
1561		// A no-op aggregator (no origin) allocates no shared state and never
1562		// announces; every handle is empty and bumps are dropped.
1563		let stats = Stats::default();
1564		assert!(stats.shared.is_none());
1565		let bs = stats.tier(Tier::External).broadcast("demo/bbb");
1566		assert!(bs.is_empty());
1567		let p = bs.publisher();
1568		let track = p.track("video");
1569		track.bytes(100);
1570		drop(track);
1571		drop(p);
1572	}
1573
1574	#[tokio::test(start_paused = true)]
1575	async fn single_broadcast_path_announced() {
1576		// No matter how many broadcasts get bumped, exactly one stats
1577		// broadcast is announced (the per-node aggregate).
1578		let (stats, origin) = test_stats(Some("sjc/1"));
1579		let mut consumer = origin.consume();
1580
1581		let bs1 = stats.tier(Tier::External).broadcast("foo/bar");
1582		let _t1 = bs1.publisher().track("video");
1583		let bs2 = stats.tier(Tier::External).broadcast("baz/qux");
1584		let _t2 = bs2.publisher().track("video");
1585
1586		tokio::time::advance(Duration::from_millis(1)).await;
1587		let (path, broadcast) = consumer.announced().await.expect("expected announce");
1588		assert!(broadcast.is_some());
1589		assert_eq!(path.as_str(), ".stats/node/sjc/1");
1590	}
1591
1592	#[tokio::test(start_paused = true)]
1593	async fn depth_splits_broadcasts_per_group() {
1594		// At depth 1 each first-segment group gets its OWN broadcast at
1595		// `.stats/<group>/node/<node>`, so a consumer can announce-scope to a
1596		// single group instead of slurping the whole node's stats.
1597		let origin = Origin::random().produce();
1598		let stats = Stats::new(
1599			StatsConfig::new()
1600				.with_origin(origin.clone())
1601				.with_node(PathOwned::from("sjc".to_string()))
1602				.with_depth(1),
1603		);
1604		let mut consumer = origin.consume();
1605
1606		let _t1 = stats
1607			.tier(Tier::External)
1608			.broadcast("acme/foo")
1609			.publisher()
1610			.track("video");
1611		let _t2 = stats
1612			.tier(Tier::External)
1613			.broadcast("globex/bar")
1614			.publisher()
1615			.track("video");
1616
1617		// A publish tick (unlike depth 0, groups aren't created until traffic
1618		// appears) spawns one broadcast per group.
1619		tokio::time::advance(Duration::from_secs(1)).await;
1620
1621		let mut announced = Vec::new();
1622		for _ in 0..2 {
1623			let (path, broadcast) = consumer.announced().await.expect("expected announce");
1624			assert!(broadcast.is_some());
1625			announced.push(path.as_str().to_string());
1626		}
1627		announced.sort();
1628		assert_eq!(
1629			announced,
1630			vec![".stats/acme/node/sjc".to_string(), ".stats/globex/node/sjc".to_string()]
1631		);
1632	}
1633
1634	#[tokio::test(start_paused = true)]
1635	async fn task_announces_without_node_suffix() {
1636		let origin = Origin::random().produce();
1637		let stats = Stats::new(StatsConfig::new().with_origin(origin.clone()));
1638		let mut consumer = origin.consume();
1639
1640		let bs = stats.tier(Tier::External).broadcast("foo/bar");
1641		let _t = bs.publisher().track("video");
1642
1643		tokio::time::advance(Duration::from_millis(1)).await;
1644		let (path, broadcast) = consumer.announced().await.expect("expected announce");
1645		assert!(broadcast.is_some());
1646		assert_eq!(path.as_str(), ".stats/node");
1647	}
1648
1649	/// Drives the snapshot task forward by `count` ticks. In paused-time
1650	/// tests, `tokio::time::advance` doesn't poll spawned tasks itself; we
1651	/// have to combine it with explicit awaits. This helper interleaves
1652	/// `advance` with `consumer.announced()` (and later `yield_now` calls)
1653	/// so the task wakes, processes the tick, and re-parks each iteration.
1654	async fn drive_ticks(count: u32) {
1655		for _ in 0..count {
1656			tokio::time::advance(Duration::from_secs(1)).await;
1657			// Yield several times to let the task wake, snapshot, write the
1658			// frame, and re-await the next tick.
1659			for _ in 0..4 {
1660				tokio::task::yield_now().await;
1661			}
1662		}
1663	}
1664
1665	#[tokio::test(start_paused = true)]
1666	async fn live_entry_kept_while_idle() {
1667		// A broadcast with a live announce guard but no traffic must stay in
1668		// the map indefinitely: announced != announced_closed means a
1669		// subscription could still begin at any moment.
1670		let (stats, _origin) = test_stats(Some("sjc"));
1671		let key = PathOwned::from("foo/bar".to_string());
1672		let bs = stats.tier(Tier::External).broadcast("foo/bar");
1673		let guard = bs.publisher();
1674
1675		drive_ticks(5).await;
1676		assert!(
1677			stats.shared().entries.lock().contains_key(&key),
1678			"announced-but-idle broadcast must stay while the guard is held"
1679		);
1680
1681		drop(guard);
1682		drop(bs);
1683		// announced == announced_closed now, and no guard holds the Arc, so
1684		// the entry is dropped on the next tick.
1685		drive_ticks(1).await;
1686		assert!(
1687			!stats.shared().entries.lock().contains_key(&key),
1688			"entry dropped once the announce guard closes"
1689		);
1690	}
1691
1692	#[tokio::test(start_paused = true)]
1693	async fn entry_dropped_once_fully_closed() {
1694		// Once every open counter equals its `*_closed` counterpart and no
1695		// guard holds the Arc, the entry is removed the very next tick.
1696		let (stats, _origin) = test_stats(Some("sjc"));
1697		let key = PathOwned::from("foo/bar".to_string());
1698		let bs = stats.tier(Tier::External).broadcast("foo/bar");
1699		let track = bs.publisher().track("video");
1700
1701		drive_ticks(1).await;
1702		assert!(
1703			stats.shared().entries.lock().contains_key(&key),
1704			"live entry present while the track guard is held"
1705		);
1706
1707		drop(track);
1708		drop(bs);
1709		drive_ticks(1).await;
1710		assert!(
1711			!stats.shared().entries.lock().contains_key(&key),
1712			"fully-closed entry dropped on the next tick"
1713		);
1714	}
1715
1716	#[tokio::test(start_paused = true)]
1717	async fn frame_emits_expected_counters() {
1718		let (stats, origin) = test_stats(Some("sjc"));
1719		let mut consumer = origin.consume();
1720		let bs = stats.tier(Tier::External).broadcast("foo/bar");
1721		let track = bs.publisher().track("video");
1722		track.bytes(42);
1723		track.frame();
1724		let sessions = stats.tier(Tier::External).publisher_broadcasts();
1725		let _sub = sessions.subscribe("foo/bar");
1726
1727		tokio::time::advance(Duration::from_millis(1100)).await;
1728
1729		let (_path, broadcast) = consumer.announced().await.expect("expected announce");
1730		let broadcast = broadcast.expect("active");
1731		let track = broadcast
1732			.subscribe_track(&Track {
1733				name: "publisher.json".into(),
1734				priority: 0,
1735			})
1736			.expect("subscribe");
1737		let frame = read_frame(track).await;
1738		let snap = frame.get("foo/bar").expect("foo/bar entry");
1739		assert_eq!(snap.announced, 1, "publisher() guard bumps announced");
1740		assert_eq!(snap.broadcasts, 1, "one session subscribed");
1741		assert_eq!(snap.subscriptions, 1);
1742		assert_eq!(snap.bytes, 42);
1743		assert_eq!(snap.frames, 1);
1744	}
1745
1746	#[tokio::test(start_paused = true)]
1747	async fn announced_bytes_recorded_per_side() {
1748		// Path-keyed announce-byte recording is isolated per side, accumulates,
1749		// works without holding a lifetime guard, and doesn't touch the payload
1750		// `bytes` counter.
1751		let (stats, _origin) = test_stats(Some("sjc"));
1752		let bs = stats.tier(Tier::External).broadcast("foo/bar");
1753		bs.publisher_announced_bytes(40);
1754		bs.publisher_announced_bytes(2);
1755		bs.subscriber_announced_bytes(7);
1756
1757		let entries = stats.shared().entries.lock();
1758		let entry = entries.get(&PathOwned::from("foo/bar")).expect("entry");
1759		let pub_ext = entry.publisher[Tier::External.idx()].snapshot();
1760		let sub_ext = entry.subscriber[Tier::External.idx()].snapshot();
1761		assert_eq!(pub_ext.announced_bytes, 42, "publisher announce bytes accumulate");
1762		assert_eq!(pub_ext.bytes, 0, "announce bytes are not payload bytes");
1763		assert_eq!(sub_ext.announced_bytes, 7, "subscriber side tracked independently");
1764	}
1765
1766	#[tokio::test(start_paused = true)]
1767	async fn announced_bytes_surfaces_in_frame() {
1768		let (stats, origin) = test_stats(Some("sjc"));
1769		let mut consumer = origin.consume();
1770		let bs = stats.tier(Tier::External).broadcast("foo/bar");
1771		let _guard = bs.publisher();
1772		bs.publisher_announced_bytes(123);
1773
1774		tokio::time::advance(Duration::from_millis(1100)).await;
1775
1776		let (_path, broadcast) = consumer.announced().await.expect("announce");
1777		let broadcast = broadcast.expect("active");
1778		let track = broadcast
1779			.subscribe_track(&Track {
1780				name: "publisher.json".into(),
1781				priority: 0,
1782			})
1783			.expect("subscribe");
1784		let frame = read_frame(track).await;
1785		let snap = frame.get("foo/bar").expect("foo/bar entry");
1786		assert_eq!(snap.announced, 1);
1787		assert_eq!(snap.announced_bytes, 123);
1788	}
1789
1790	#[tokio::test(start_paused = true)]
1791	async fn announced_decouples_from_broadcasts() {
1792		// publisher() (announce) with no subscription should bump announced but
1793		// NOT broadcasts (which only counts sessions with an active sub).
1794		let (stats, origin) = test_stats(Some("sjc"));
1795		let mut consumer = origin.consume();
1796		let bs = stats.tier(Tier::External).broadcast("foo/bar");
1797		let _guard = bs.publisher();
1798
1799		tokio::time::advance(Duration::from_millis(1100)).await;
1800
1801		let (_path, broadcast) = consumer.announced().await.expect("announce");
1802		let broadcast = broadcast.expect("active");
1803		let track = broadcast
1804			.subscribe_track(&Track {
1805				name: "publisher.json".into(),
1806				priority: 0,
1807			})
1808			.expect("subscribe");
1809		let frame = read_frame(track).await;
1810		let snap = frame.get("foo/bar").expect("foo/bar entry");
1811		assert_eq!(snap.announced, 1);
1812		assert_eq!(snap.broadcasts, 0, "no subscription, no broadcasts sentinel");
1813		assert_eq!(snap.subscriptions, 0);
1814	}
1815
1816	#[tokio::test(start_paused = true)]
1817	async fn short_lived_sub_is_surfaced() {
1818		// A subscription that opens AND closes within a single tick window
1819		// must still surface as a complete broadcasts open/close cycle. The
1820		// cumulative counters retain broadcasts=1/broadcasts_closed=1, and the
1821		// change-driven inclusion surfaces the entry even though it's net-idle
1822		// by snapshot time.
1823		let (stats, origin) = test_stats(Some("sjc"));
1824		let mut consumer = origin.consume();
1825		let bs = stats.tier(Tier::External).broadcast("foo/bar");
1826		let sessions = stats.tier(Tier::External).publisher_broadcasts();
1827		{
1828			let track = bs.publisher().track("video");
1829			track.bytes(123);
1830			track.frame();
1831			let _sub = sessions.subscribe("foo/bar");
1832			// track + sub dropped here, all within tick 1
1833		}
1834
1835		tokio::time::advance(Duration::from_millis(1100)).await;
1836
1837		let (_path, broadcast) = consumer.announced().await.expect("announce");
1838		let broadcast = broadcast.expect("active");
1839		let track = broadcast
1840			.subscribe_track(&Track {
1841				name: "publisher.json".into(),
1842				priority: 0,
1843			})
1844			.expect("subscribe");
1845		let frame = read_frame(track).await;
1846		let snap = frame.get("foo/bar").expect("foo/bar entry");
1847		// One session opened then closed a subscription within the tick.
1848		assert_eq!(snap.subscriptions, 1);
1849		assert_eq!(snap.subscriptions_closed, 1);
1850		assert_eq!(snap.broadcasts, 1, "one session subscribed");
1851		assert_eq!(snap.broadcasts_closed, 1);
1852		assert_eq!(snap.bytes, 123);
1853		assert_eq!(snap.frames, 1);
1854	}
1855
1856	#[tokio::test(start_paused = true)]
1857	async fn multiple_subs_count_as_one_broadcast() {
1858		// Two concurrent subs from the SAME session count as one broadcast, not
1859		// two: broadcasts is "distinct sessions with >=1 active sub", not
1860		// "subscription count". broadcasts_closed only bumps once the session's
1861		// last sub for the broadcast closes.
1862		let (stats, _origin) = test_stats(Some("sjc"));
1863		let bs = stats.tier(Tier::External).broadcast("foo/bar");
1864		let sessions = stats.tier(Tier::External).publisher_broadcasts();
1865		let pub_guard = bs.publisher();
1866		let t1 = pub_guard.track("video");
1867		let t2 = pub_guard.track("audio");
1868		let s1 = sessions.subscribe("foo/bar");
1869		let s2 = sessions.subscribe("foo/bar");
1870
1871		let raw = || {
1872			let entries = stats.shared().entries.lock();
1873			let entry = entries.get(&PathOwned::from("foo/bar")).expect("entry");
1874			entry.publisher[Tier::External.idx()].snapshot()
1875		};
1876
1877		let r = raw();
1878		assert_eq!(r.subscriptions, 2, "two track subs");
1879		assert_eq!(r.subscriptions_closed, 0, "neither dropped yet");
1880		assert_eq!(r.broadcasts, 1, "one session => one broadcast");
1881		assert_eq!(r.broadcasts_closed, 0);
1882
1883		drop(s1);
1884		assert_eq!(raw().broadcasts_closed, 0, "session still has a sub open");
1885
1886		drop(s2);
1887		drop(t1);
1888		drop(t2);
1889		let r = raw();
1890		assert_eq!(r.subscriptions_closed, 2, "both track subs dropped");
1891		assert_eq!(r.broadcasts, 1);
1892		assert_eq!(r.broadcasts_closed, 1, "last sub closed => one broadcasts_closed");
1893
1894		drop(pub_guard);
1895		drop(bs);
1896	}
1897
1898	#[tokio::test(start_paused = true)]
1899	async fn distinct_sessions_count_as_separate_broadcasts() {
1900		// The viewer-count invariant: two different sessions subscribing to the
1901		// same broadcast bump broadcasts to 2 (each is a distinct viewer).
1902		let (stats, _origin) = test_stats(Some("sjc"));
1903		let viewer1 = stats.tier(Tier::External).publisher_broadcasts();
1904		let viewer2 = stats.tier(Tier::External).publisher_broadcasts();
1905
1906		let raw = || {
1907			let entries = stats.shared().entries.lock();
1908			let entry = entries.get(&PathOwned::from("foo/bar")).expect("entry");
1909			entry.publisher[Tier::External.idx()].snapshot()
1910		};
1911
1912		let s1 = viewer1.subscribe("foo/bar");
1913		assert_eq!(raw().broadcasts, 1, "one viewer");
1914		let s2 = viewer2.subscribe("foo/bar");
1915		assert_eq!(raw().broadcasts, 2, "two distinct viewers");
1916		assert_eq!(raw().broadcasts_closed, 0);
1917
1918		drop(s1);
1919		let r = raw();
1920		assert_eq!(r.broadcasts, 2, "broadcasts is cumulative");
1921		assert_eq!(r.broadcasts_closed, 1, "one viewer left");
1922		// broadcasts - broadcasts_closed = 1 remaining viewer.
1923
1924		drop(s2);
1925		assert_eq!(raw().broadcasts_closed, 2, "both viewers gone");
1926	}
1927
1928	#[tokio::test(start_paused = true)]
1929	async fn session_counts_by_root() {
1930		// session() counts connected sessions per auth root, independent of any
1931		// broadcast: open bumps `sessions`, drop bumps `sessions_closed`.
1932		let (stats, _origin) = test_stats(Some("sjc"));
1933		let ext = stats.tier(Tier::External);
1934
1935		let snap = |root: &str| {
1936			let map = stats.shared().sessions[Tier::External.idx()].lock();
1937			map.get(&PathOwned::from(root.to_string())).map(|c| c.snapshot())
1938		};
1939
1940		let a1 = ext.session("acme");
1941		let a2 = ext.session("acme");
1942		let b1 = ext.session("globex");
1943		assert_eq!(snap("acme"), Some((2, 0)), "two sessions under one root");
1944		assert_eq!(snap("globex"), Some((1, 0)), "a distinct root is counted separately");
1945
1946		drop(a1);
1947		assert_eq!(snap("acme"), Some((2, 1)));
1948		drop(a2);
1949		drop(b1);
1950		assert_eq!(snap("acme"), Some((2, 2)));
1951		assert_eq!(snap("globex"), Some((1, 1)));
1952	}
1953
1954	#[tokio::test(start_paused = true)]
1955	async fn session_track_surfaces_by_root() {
1956		let (stats, origin) = test_stats(Some("sjc"));
1957		let mut consumer = origin.consume();
1958		let _a = stats.tier(Tier::External).session("acme");
1959		let _b = stats.tier(Tier::External).session("acme");
1960		let _c = stats.tier(Tier::Internal).session("peer");
1961
1962		tokio::time::advance(Duration::from_millis(1100)).await;
1963
1964		let (_path, broadcast) = consumer.announced().await.expect("announce");
1965		let broadcast = broadcast.expect("active");
1966
1967		let track = broadcast
1968			.subscribe_track(&Track {
1969				name: "sessions.json".into(),
1970				priority: 0,
1971			})
1972			.expect("subscribe");
1973		let frame = read_session_frame(track).await;
1974		let snap = frame.get("acme").expect("root entry");
1975		assert_eq!(snap.sessions, 2);
1976		assert_eq!(snap.sessions_closed, 0);
1977		assert!(
1978			!frame.contains_key("peer"),
1979			"internal session must not appear on the external track"
1980		);
1981
1982		let int_track = broadcast
1983			.subscribe_track(&Track {
1984				name: "internal/sessions.json".into(),
1985				priority: 0,
1986			})
1987			.expect("subscribe");
1988		let snap = *read_session_frame(int_track).await.get("peer").expect("internal entry");
1989		assert_eq!(snap.sessions, 1);
1990	}
1991
1992	#[tokio::test(start_paused = true)]
1993	async fn session_root_dropped_when_empty() {
1994		// Once the last session under a root disconnects, the root leaves the
1995		// map on the next tick (its final snapshot already emitted).
1996		let (stats, _origin) = test_stats(Some("sjc"));
1997		let key = PathOwned::from("acme");
1998		let session = stats.tier(Tier::External).session("acme");
1999
2000		drive_ticks(1).await;
2001		assert!(
2002			stats.shared().sessions[Tier::External.idx()].lock().contains_key(&key),
2003			"root present while a session is connected"
2004		);
2005
2006		drop(session);
2007		drive_ticks(1).await;
2008		assert!(
2009			!stats.shared().sessions[Tier::External.idx()].lock().contains_key(&key),
2010			"root GC'd after the last session leaves"
2011		);
2012	}
2013
2014	#[tokio::test(start_paused = true)]
2015	async fn unused_slots_dont_surface() {
2016		// A broadcast that only sees External Publisher traffic must NOT
2017		// appear in the other three tracks with zero counters. Regression
2018		// for the "None != Some(default)" first-tick change-detection bug:
2019		// without the unwrap_or_default fix, every entry would surface
2020		// once in every track even when only one slot had real activity.
2021		let (stats, origin) = test_stats(Some("sjc"));
2022		let mut consumer = origin.consume();
2023		let bs = stats.tier(Tier::External).broadcast("foo/bar");
2024		let track = bs.publisher().track("video");
2025		track.frame();
2026
2027		drive_ticks(2).await;
2028
2029		let (_path, broadcast) = consumer.announced().await.expect("announce");
2030		let broadcast = broadcast.expect("active");
2031
2032		// External publisher slot SHOULD include foo/bar.
2033		let pub_track = broadcast
2034			.subscribe_track(&Track {
2035				name: "publisher.json".into(),
2036				priority: 0,
2037			})
2038			.expect("subscribe");
2039		assert!(
2040			read_frame(pub_track).await.contains_key("foo/bar"),
2041			"publisher.json must include the active foo/bar entry"
2042		);
2043
2044		// The other three slots had zero activity. The first frame on
2045		// each must be `{}`, not `{"foo/bar": {all zeros}}`.
2046		for name in ["subscriber.json", "internal/publisher.json", "internal/subscriber.json"] {
2047			let t = broadcast
2048				.subscribe_track(&Track {
2049					name: name.into(),
2050					priority: 0,
2051				})
2052				.expect("subscribe");
2053			let frame = read_frame(t).await;
2054			assert!(
2055				frame.is_empty(),
2056				"{name} must be empty for an entry with no activity on that slot, got {frame:?}",
2057			);
2058		}
2059	}
2060
2061	#[test]
2062	fn snapshot_reads_closed_before_open() {
2063		// Reading closed counters before their open counterparts is the
2064		// guarantee that the emitted Snapshot never shows close > open
2065		// under concurrent bumps. This unit-test pins the ordering at the
2066		// source level so a future refactor that re-orders the loads
2067		// trips the test.
2068		let src = include_str!("stats.rs");
2069		// Find the body of `impl Counters { fn snapshot(...) ... }` and
2070		// check the line order.
2071		let body_start = src
2072			.find("fn snapshot(&self) -> RawCounts")
2073			.expect("snapshot fn present");
2074		let body = &src[body_start..];
2075		let closed_pos = body.find("self.announced_closed.load").expect("announced_closed load");
2076		let open_pos = body.find("self.announced.load(").expect("announced load");
2077		assert!(
2078			closed_pos < open_pos,
2079			"announced_closed must be loaded before announced; reversing breaks the open>=closed invariant",
2080		);
2081		let subs_closed_pos = body
2082			.find("self.subscriptions_closed.load")
2083			.expect("subscriptions_closed load");
2084		let subs_pos = body.find("self.subscriptions.load").expect("subscriptions load");
2085		assert!(
2086			subs_closed_pos < subs_pos,
2087			"subscriptions_closed must be loaded before subscriptions",
2088		);
2089		let bcast_closed_pos = body
2090			.find("self.broadcasts_closed.load")
2091			.expect("broadcasts_closed load");
2092		let bcast_pos = body.find("self.broadcasts.load").expect("broadcasts load");
2093		assert!(
2094			bcast_closed_pos < bcast_pos,
2095			"broadcasts_closed must be loaded before broadcasts",
2096		);
2097	}
2098
2099	#[test]
2100	fn session_snapshot_reads_closed_before_open() {
2101		// Same `closed`-before-`open` invariant as `Counters::snapshot`, pinned
2102		// at the source level so a reordering refactor can't let
2103		// `sessions_closed > sessions` leak into an emitted session frame.
2104		let src = include_str!("stats.rs");
2105		let body_start = src
2106			.find("fn snapshot(&self) -> (u64, u64)")
2107			.expect("SessionCounters::snapshot fn present");
2108		let body = &src[body_start..];
2109		let closed_pos = body.find("self.sessions_closed.load").expect("sessions_closed load");
2110		let open_pos = body.find("self.sessions.load").expect("sessions load");
2111		assert!(closed_pos < open_pos, "sessions_closed must be loaded before sessions",);
2112	}
2113
2114	async fn read_frame(mut track: crate::TrackConsumer) -> BTreeMap<String, Snapshot> {
2115		let bytes = track.read_frame().await.expect("ok").expect("frame");
2116		serde_json::from_slice(&bytes).expect("json parse")
2117	}
2118
2119	async fn read_session_frame(mut track: crate::TrackConsumer) -> BTreeMap<String, SessionSnapshot> {
2120		let bytes = track.read_frame().await.expect("ok").expect("frame");
2121		serde_json::from_slice(&bytes).expect("json parse")
2122	}
2123}