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