Skip to main content

moq_net/
stats.rs

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