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(®ional, Role::Publisher, false),
219 "region/sjc/publisher.json"
220 );
221 assert_eq!(sessions_track(&default, false), "sessions.json");
222 assert_eq!(sessions_track(®ional, true), "region/sjc/sessions.json.z");
223 }
224}