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