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