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//! - [`aggregate::Consumer`] folds a whole group's per-node broadcasts into one
12//!   merged view, so a downstream sees a project's total live traffic as if it
13//!   came from a single node.
14//!
15//! # Wire format
16//!
17//! A [`Producer`] publishes one broadcast per node at `<prefix>/node/<node>`
18//! (default prefix `.stats`), or one per group of leading broadcast-path
19//! segments at `<prefix>/<group>/node/<node>`; parse announce paths back with
20//! [`parse_node_path`]. Each [`Tier`] carries `publisher.json`,
21//! `subscriber.json`, and `sessions.json` tracks of cumulative [`Traffic`] and
22//! [`Presence`] counters, plus `.json.z` siblings encoded with
23//! [`moq_json::snapshot`]; compute names with [`traffic_track`] /
24//! [`sessions_track`]. The full contract (paths, tracks, both encodings, and
25//! counter semantics) is at <https://doc.moq.dev/concept/stats>.
26
27pub mod aggregate;
28pub mod consume;
29pub mod produce;
30
31pub use consume::Consumer;
32pub use produce::Producer;
33
34use std::collections::BTreeMap;
35
36/// Counter collection, re-exported from [`moq_net::stats`] so stats consumers
37/// can depend on this crate alone.
38pub use moq_net::stats::{Handle, Presence, Registry, Role, Tier, Traffic};
39
40use moq_net::{AsPath, Path, PathOwned};
41
42/// One frame off a traffic track: cumulative counters keyed by broadcast path.
43pub type TrafficFrame = BTreeMap<String, Traffic>;
44
45/// One frame off a sessions track: connect/disconnect gauges keyed by auth root.
46pub type SessionsFrame = BTreeMap<String, Presence>;
47
48/// Suffix appended to a plain track name for its compressed sibling.
49pub const COMPRESSED_SUFFIX: &str = ".z";
50
51/// The traffic track name for a tier and role: `<role>.json` at the prefix root
52/// on the default tier (`publisher.json` / `subscriber.json`), `<tier>/<role>.json`
53/// on a named one, plus [`COMPRESSED_SUFFIX`] when `compressed`.
54pub fn traffic_track(tier: &Tier, role: Role, compressed: bool) -> String {
55	let mut name = tier.track_name(&format!("{}.json", role.as_str()));
56	if compressed {
57		name.push_str(COMPRESSED_SUFFIX);
58	}
59	name
60}
61
62/// The sessions track name for a tier: `sessions.json` on the default tier,
63/// `<tier>/sessions.json` on a named one, plus [`COMPRESSED_SUFFIX`] when
64/// `compressed`.
65pub fn sessions_track(tier: &Tier, compressed: bool) -> String {
66	let mut name = tier.track_name("sessions.json");
67	if compressed {
68		name.push_str(COMPRESSED_SUFFIX);
69	}
70	name
71}
72
73/// A parsed stats broadcast path: `<prefix>[/<group>]/node[/<node>]`.
74/// See [`parse_node_path`].
75#[derive(Debug, Clone, PartialEq, Eq)]
76#[non_exhaustive]
77pub struct NodePath {
78	/// The grouping key: the leading broadcast-path segments selected by the
79	/// producer's `depth`, empty at depth 0.
80	pub group: PathOwned,
81	/// The node suffix, empty when the producer has no node configured.
82	pub node: PathOwned,
83}
84
85/// Parse a stats broadcast announce path published under `prefix` with the
86/// given grouping `depth`, splitting it into its group and node parts.
87///
88/// Returns `None` when the path is not under `prefix` or has no `node`
89/// category segment where one is expected (which also filters out sibling
90/// categories another producer may publish under the same prefix). A group
91/// segment literally named `node` is ambiguous and will mis-parse; don't name
92/// groups that.
93pub fn parse_node_path(prefix: impl AsPath, depth: usize, path: impl AsPath) -> Option<NodePath> {
94	let prefix = prefix.as_path();
95	let path = path.as_path();
96	let rest = if prefix.is_empty() {
97		path.as_str()
98	} else {
99		path.as_str().strip_prefix(prefix.as_str())?.strip_prefix('/')?
100	};
101
102	// The group is at most `depth` segments (fewer when the broadcast path was
103	// shorter), so `node` is the first literal "node" segment at or before
104	// index `depth`.
105	let mut segments = rest.split('/');
106	let mut group: Vec<&str> = Vec::new();
107	loop {
108		let segment = segments.next()?;
109		if segment == "node" {
110			break;
111		}
112		if group.len() >= depth {
113			return None;
114		}
115		group.push(segment);
116	}
117
118	let node = segments.collect::<Vec<_>>().join("/");
119	Some(NodePath {
120		group: Path::new(&group.join("/")).to_owned(),
121		node: Path::new(&node).to_owned(),
122	})
123}
124
125/// Errors produced while publishing or consuming stats.
126#[derive(thiserror::Error, Debug, Clone)]
127#[non_exhaustive]
128pub enum Error {
129	/// An error from the underlying track or broadcast.
130	#[error(transparent)]
131	Net(#[from] moq_net::Error),
132
133	/// An error decoding or encoding a stats frame.
134	#[error(transparent)]
135	Json(#[from] moq_json::Error),
136}
137
138/// A [`Result`](std::result::Result) using this crate's [`Error`].
139pub type Result<T> = std::result::Result<T, Error>;
140
141#[cfg(test)]
142mod tests {
143	use super::*;
144
145	#[test]
146	fn parse_node_path_variants() {
147		let parse = |depth, path| parse_node_path(".stats", depth, path);
148
149		// Depth 0: no group segment.
150		assert_eq!(
151			parse(0, ".stats/node/sjc"),
152			Some(NodePath {
153				group: Path::empty().to_owned(),
154				node: Path::new("sjc").to_owned(),
155			})
156		);
157		assert_eq!(
158			parse(0, ".stats/node/sjc/1").unwrap().node,
159			Path::new("sjc/1").to_owned(),
160			"multi-segment node"
161		);
162		assert_eq!(
163			parse(0, ".stats/node"),
164			Some(NodePath {
165				group: Path::empty().to_owned(),
166				node: Path::empty().to_owned(),
167			}),
168			"nodeless path"
169		);
170
171		// Depth 1: one group segment, as published per tenant.
172		assert_eq!(
173			parse(1, ".stats/acme/node/sjc"),
174			Some(NodePath {
175				group: Path::new("acme").to_owned(),
176				node: Path::new("sjc").to_owned(),
177			})
178		);
179		// A shorter broadcast path yields a shorter group; still parses.
180		assert_eq!(parse(1, ".stats/node/sjc").unwrap().group, Path::empty().to_owned());
181
182		// Not ours: wrong prefix, sibling category, group deeper than depth.
183		assert_eq!(parse(0, "other/node/sjc"), None);
184		assert_eq!(parse(1, ".stats/acme/vod/sjc"), None, "sibling category filtered");
185		assert_eq!(parse(0, ".stats/acme/node/sjc"), None, "group deeper than depth");
186	}
187
188	#[test]
189	fn track_names() {
190		let default = Tier::default();
191		let regional = Tier::new("region/sjc");
192		assert_eq!(traffic_track(&default, Role::Publisher, false), "publisher.json");
193		assert_eq!(traffic_track(&default, Role::Subscriber, true), "subscriber.json.z");
194		assert_eq!(
195			traffic_track(&regional, Role::Publisher, false),
196			"region/sjc/publisher.json"
197		);
198		assert_eq!(sessions_track(&default, false), "sessions.json");
199		assert_eq!(sessions_track(&regional, true), "region/sjc/sessions.json.z");
200	}
201}