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