Skip to main content

moq_stats/
lib.rs

1//! Publish and consume MoQ traffic stats.
2//!
3//! `moq-net` collects per-session traffic counters in a
4//! [`stats::Registry`](moq_net::stats::Registry); this crate turns that
5//! registry into MoQ broadcasts and back:
6//!
7//! - [`Producer`] drains a registry on an interval and publishes the counters
8//!   as JSON tracks on an origin.
9//! - [`Consumer`] subscribes to one published stats broadcast and yields typed
10//!   frames, for aggregators, dashboards, and billing meters.
11//!
12//! # Wire format
13//!
14//! A [`Producer`] publishes one broadcast per node at `<prefix>/node/<node>`
15//! (default prefix `.stats`; the node suffix disambiguates relays sharing a
16//! cluster origin and may be multi-segment, e.g. `sjc/1`). A grouping `depth`
17//! splits that into one broadcast per leading broadcast-path segments at
18//! `<prefix>/<group>/node/<node>`, so a consumer can announce-scope to a
19//! single group. Parse announce paths back with [`parse_node_path`].
20//!
21//! Traffic is bucketed by [`Tier`] (an arbitrary label chosen by business
22//! logic: billing class, region, ...). The default tier is unprefixed; a named
23//! tier prefixes its track names with its label. Each broadcast carries, per
24//! tier, a publisher (egress) and a subscriber (ingress) traffic track plus a
25//! sessions track, each in a plain and a compressed flavor:
26//!
27//! * `publisher.json` / `subscriber.json`: each frame is a JSON object mapping
28//!   broadcast path to a cumulative [`Traffic`] snapshot ([`TrafficFrame`]),
29//!   one full snapshot per frame.
30//! * `sessions.json`: each frame maps auth root to a cumulative [`Presence`]
31//!   gauge ([`SessionsFrame`]), counting connected sessions regardless of data
32//!   flow.
33//! * `<name>.json.z`: a compressed sibling of each of the above, encoded with
34//!   [`moq_json::snapshot`] (group-scoped DEFLATE plus RFC 7396 merge-patch
35//!   deltas). Since successive stats frames are nearly identical, this is a
36//!   fraction of the plain track's bytes; read it with [`Consumer`] (or
37//!   `moq_json` directly), not as raw JSON frames.
38//!
39//! Named-tier tracks (`<tier>/publisher.json`, ...) are created the first time
40//! traffic records under that label; default-tier tracks always exist and hold
41//! `{}` while idle. Compute names with [`traffic_track`] / [`sessions_track`].
42//!
43//! An entry appears in a frame while it is live (an open counter still exceeds
44//! its `*_closed` counterpart, so traffic could resume at any moment) or on
45//! the tick its snapshot changed, then is dropped once fully closed. Counters
46//! are cumulative and monotonic: a downstream aggregator computes rates from
47//! successive snapshots, and a counter going backwards means the relay
48//! restarted or the entry was garbage collected and re-created, so consumers
49//! should treat a decrease as a fresh segment.
50
51mod consume;
52mod produce;
53
54pub use consume::{Consumer, ConsumerConfig, SessionsConsumer, TrafficConsumer};
55pub use produce::{Producer, ProducerConfig};
56
57use std::collections::BTreeMap;
58
59/// Counter collection, re-exported from [`moq_net::stats`] so stats consumers
60/// can depend on this crate alone.
61pub use moq_net::stats::{Handle, Presence, Registry, Role, Tier, Traffic};
62
63use moq_net::{AsPath, Path, PathOwned};
64
65/// One frame off a traffic track: cumulative counters keyed by broadcast path.
66pub type TrafficFrame = BTreeMap<String, Traffic>;
67
68/// One frame off a sessions track: connect/disconnect gauges keyed by auth root.
69pub type SessionsFrame = BTreeMap<String, Presence>;
70
71/// Suffix appended to a plain track name for its compressed sibling.
72pub const COMPRESSED_SUFFIX: &str = ".z";
73
74/// The traffic track name for a tier and role: `<role>.json` at the prefix root
75/// on the default tier (`publisher.json` / `subscriber.json`), `<tier>/<role>.json`
76/// on a named one, plus [`COMPRESSED_SUFFIX`] when `compressed`.
77pub fn traffic_track(tier: &Tier, role: Role, compressed: bool) -> String {
78	let mut name = tier.track_name(&format!("{}.json", role.as_str()));
79	if compressed {
80		name.push_str(COMPRESSED_SUFFIX);
81	}
82	name
83}
84
85/// The sessions track name for a tier: `sessions.json` on the default tier,
86/// `<tier>/sessions.json` on a named one, plus [`COMPRESSED_SUFFIX`] when
87/// `compressed`.
88pub fn sessions_track(tier: &Tier, compressed: bool) -> String {
89	let mut name = tier.track_name("sessions.json");
90	if compressed {
91		name.push_str(COMPRESSED_SUFFIX);
92	}
93	name
94}
95
96/// A parsed stats broadcast path: `<prefix>[/<group>]/node[/<node>]`.
97/// See [`parse_node_path`].
98#[derive(Debug, Clone, PartialEq, Eq)]
99#[non_exhaustive]
100pub struct NodePath {
101	/// The grouping key: the leading broadcast-path segments selected by the
102	/// producer's `depth`, empty at depth 0.
103	pub group: PathOwned,
104	/// The node suffix, empty when the producer has no node configured.
105	pub node: PathOwned,
106}
107
108/// Parse a stats broadcast announce path published under `prefix` with the
109/// given grouping `depth`, splitting it into its group and node parts.
110///
111/// Returns `None` when the path is not under `prefix` or has no `node`
112/// category segment where one is expected (which also filters out sibling
113/// categories another producer may publish under the same prefix). A group
114/// segment literally named `node` is ambiguous and will mis-parse; don't name
115/// groups that.
116pub fn parse_node_path(prefix: impl AsPath, depth: usize, path: impl AsPath) -> Option<NodePath> {
117	let prefix = prefix.as_path();
118	let path = path.as_path();
119	let rest = if prefix.is_empty() {
120		path.as_str()
121	} else {
122		path.as_str().strip_prefix(prefix.as_str())?.strip_prefix('/')?
123	};
124
125	// The group is at most `depth` segments (fewer when the broadcast path was
126	// shorter), so `node` is the first literal "node" segment at or before
127	// index `depth`.
128	let mut segments = rest.split('/');
129	let mut group: Vec<&str> = Vec::new();
130	loop {
131		let segment = segments.next()?;
132		if segment == "node" {
133			break;
134		}
135		if group.len() >= depth {
136			return None;
137		}
138		group.push(segment);
139	}
140
141	let node = segments.collect::<Vec<_>>().join("/");
142	Some(NodePath {
143		group: Path::new(&group.join("/")).to_owned(),
144		node: Path::new(&node).to_owned(),
145	})
146}
147
148/// Errors produced while publishing or consuming stats.
149#[derive(thiserror::Error, Debug, Clone)]
150#[non_exhaustive]
151pub enum Error {
152	/// An error from the underlying track or broadcast.
153	#[error(transparent)]
154	Net(#[from] moq_net::Error),
155
156	/// An error decoding or encoding a stats frame.
157	#[error(transparent)]
158	Json(#[from] moq_json::Error),
159}
160
161/// A [`Result`](std::result::Result) using this crate's [`Error`].
162pub type Result<T> = std::result::Result<T, Error>;
163
164#[cfg(test)]
165mod tests {
166	use super::*;
167
168	#[test]
169	fn parse_node_path_variants() {
170		let parse = |depth, path| parse_node_path(".stats", depth, path);
171
172		// Depth 0: no group segment.
173		assert_eq!(
174			parse(0, ".stats/node/sjc"),
175			Some(NodePath {
176				group: Path::empty().to_owned(),
177				node: Path::new("sjc").to_owned(),
178			})
179		);
180		assert_eq!(
181			parse(0, ".stats/node/sjc/1").unwrap().node,
182			Path::new("sjc/1").to_owned(),
183			"multi-segment node"
184		);
185		assert_eq!(
186			parse(0, ".stats/node"),
187			Some(NodePath {
188				group: Path::empty().to_owned(),
189				node: Path::empty().to_owned(),
190			}),
191			"nodeless path"
192		);
193
194		// Depth 1: one group segment, as published per tenant.
195		assert_eq!(
196			parse(1, ".stats/acme/node/sjc"),
197			Some(NodePath {
198				group: Path::new("acme").to_owned(),
199				node: Path::new("sjc").to_owned(),
200			})
201		);
202		// A shorter broadcast path yields a shorter group; still parses.
203		assert_eq!(parse(1, ".stats/node/sjc").unwrap().group, Path::empty().to_owned());
204
205		// Not ours: wrong prefix, sibling category, group deeper than depth.
206		assert_eq!(parse(0, "other/node/sjc"), None);
207		assert_eq!(parse(1, ".stats/acme/vod/sjc"), None, "sibling category filtered");
208		assert_eq!(parse(0, ".stats/acme/node/sjc"), None, "group deeper than depth");
209	}
210
211	#[test]
212	fn track_names() {
213		let default = Tier::default();
214		let regional = Tier::new("region/sjc");
215		assert_eq!(traffic_track(&default, Role::Publisher, false), "publisher.json");
216		assert_eq!(traffic_track(&default, Role::Subscriber, true), "subscriber.json.z");
217		assert_eq!(
218			traffic_track(&regional, Role::Publisher, false),
219			"region/sjc/publisher.json"
220		);
221		assert_eq!(sessions_track(&default, false), "sessions.json");
222		assert_eq!(sessions_track(&regional, true), "region/sjc/sessions.json.z");
223	}
224}