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(®ional, Role::Publisher, false),
196 "region/sjc/publisher.json"
197 );
198 assert_eq!(sessions_track(&default, false), "sessions.json");
199 assert_eq!(sessions_track(®ional, true), "region/sjc/sessions.json.z");
200 }
201}