Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,37 @@ is a replacement. An export that fails while the broadcast stays up, such as on
a codec TS cannot carry, exits 1 without lingering. Only `ts` can mark a break,
so the other formats refuse `--linger`.

## Stats and feedback

`import --stats` names a stats track in the catalog and reports what each
rendition has written, about once a second while someone subscribes, with the
connection's RTT, estimated send rate, and loss. It covers the stdin imports;
capture, the gateways, `archive`, and `ts --program all` refuse it.

`export --echo <name>` answers a publisher that solicits feedback: under the
catalog's `echo` path it publishes `<name>.echo`, reporting what each rendition
the export reads has received and its newest arrival, until the export ends.
The name is one path segment. It covers the stdout container exports and reads
the solicitation from the hang catalog, so `--catalog-format msf` refuses it.

The `stats` and `echo` sinks print reports as JSON lines. `export stats`
follows one broadcast's stats track until the broadcast ends; `export echo`
prints `{"broadcast", "feedback"}` for every `.echo` broadcast under
`--broadcast` as they come and go.

```bash
moq ... --broadcast event import --stats ts < feed.ts
moq ... --broadcast event export stats
moq ... --broadcast event export --echo alice ts > out.ts
moq ... --broadcast viewers export echo
```

Counters are cumulative for the life of the process: a rendition that ends
keeps its final counts, and one that returns under the same ID continues them.
An fMP4 import counts samples as frames. Encoder counters (`skipped`,
`bitrate`) and playback feedback (`decoded`, `late`, `stalls`) are not reported
yet. [Stats](/concept/stats#media-reports) has the schema.

## Debugging

`RUST_LOG=debug` prints the negotiated version and every subscription.
Expand Down
9 changes: 6 additions & 3 deletions doc/concept/stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ with unavailable transport measurements absent. Missing rendition counters
mean zero, and JavaScript preserves omission. A known-zero gauge or transport
measurement stays explicit, so it remains distinct from unavailable.

These types do not enable automatic reporting in a publisher or player.
See the [hang draft](/draft/moq-hang#media-stats) for track framing,
field units, and the feedback convention.
`moq import --stats` and `moq export --echo` report them from the
[CLI](/bin/cli#stats-and-feedback). An import counts the frames and payload
bytes it writes to each rendition, and video keyframes; an export counts what
it reads and records its newest arrival. See the
[hang draft](/draft/moq-hang#media-stats) for track framing, field units, and
the feedback convention.

## Broadcasts

Expand Down
4 changes: 2 additions & 2 deletions quest/m2/stats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ connection view and the dashboard flow.

## Required

- [Rust reporters](/quest/m2/stats/rust.md) - the CLI, players, encoders, and
moq-mux remuxes publish stats and feedback
- [Rust reporters](/quest/m2/stats/rust.md) - the player, encoders, and
gateways publish stats and feedback, as the CLI remuxes do
- [Browser reporters](/quest/m2/stats/js.md) - `<moq-publish>` publishes
stats and `<moq-watch>` publishes feedback
- [Client health](/quest/m2/stats/health.md) - two snapshots become a
Expand Down
67 changes: 27 additions & 40 deletions quest/m2/stats/rust.md
Original file line number Diff line number Diff line change
@@ -1,58 +1,45 @@
# [M] The Rust CLI, players, encoders, and remuxes report stats and feedback
# [M] The Rust player, encoders, and gateways report stats and feedback

## Goal

- `moq import --stats` and `moq publish --stats` announce a stats track in
the catalog and fill it with what they send.
- `moq play` and `moq export`, given `--echo <name>`, publish an `.echo`
broadcast under each watched catalog's echo path and fill its feedback
track.
- `moq export stats <broadcast>` prints a broadcast's stats snapshots, and
`moq export echo <prefix>` prints the feedback of every `.echo` broadcast
under a prefix, as JSON lines.
- `moq import capture --stats` announces a stats track in the catalog and
fills it with what its encoders send, `skipped` and `bitrate` included.
- `moq play`, given `--echo <name>`, publishes an `.echo` broadcast under each
watched catalog's echo path and fills its feedback track with what it
received, decoded, and played.
- The gateway imports (`hls`, `rtmp`, `srt`, `rtc`) and `archive` take
`--stats`.

## Plan

- `moq import --stats` for the stdin imports, `moq export --echo`, and the
`export stats` and `export echo` sinks already report what the `moq-mux`
importers write and exporters read (`rs/moq-mux/src/stats.rs`,
`rs/moq-cli/src/report.rs`). This quest adds the counters they lack and
reuses the rest; `import --stats` refuses capture and the gateways until it
lands.
- Counters come from the source, per track, as cumulative `stats()`
snapshots: no callback, no per-frame lock.
- Publisher: the `moq-video` and `moq-audio` encode producers count frames,
bytes, keyframes, and drops. The `moq-mux` importers count the frames and
bytes they write per track. A remux never encodes, so without them the
CLI would have nothing to report for `moq import`.
- Publisher: the `moq-video` and `moq-audio` encode producers create their
tracks directly on the broadcast, so they count frames, bytes, keyframes,
and drops themselves, and expose the target bitrate.
- Viewer: the decode and render paths count received, decoded, late,
stalled, underruns, and errors. The `moq-mux` exporters count the frames
and bytes they receive per track, so `moq export --echo` reports a row
for each rendition it remuxes. `underruns` in
stalled, underruns, and errors. `underruns` in
`rs/moq-audio/src/playback` is already counted privately.
- `moq-cli` folds the counters into `hang::stats::Snapshot` on the stats
interval and writes it through `moq_json::snapshot`, with the `.z` sibling.
`transport` comes from the connection's `ConnectionStats`.
Decided in the quest audit: this quest owns generic per-rendition reporting;
[TS health stats](/quest/m2/ts-health-stats.md) owns serializing and flattening
TS container counters under `mpegts` in publisher stats and export `.echo`.
That integration stays in m2 so this quest does not duplicate its work.
- Feedback: after reading a catalog with an `echo` section, the player
resolves the echo path against the broadcast, appends `<name>.echo`, and
publishes there with the fixed feedback
track, keyed by the catalog's rendition IDs. Each catalog update
reconciles it: a removed `echo` section, a changed path, or no longer
watching unannounces the old broadcast. A name that is not a single path segment is refused at
parse time.
- A viewer given `--echo` with `--catalog-format msf` refuses: the MSF
catalog does not carry an `echo` solicitation. Imports always emit the
MSF catalog beside hang, omitting `stats` and `echo` from MSF.
- `moq export stats` and `moq export echo` are sinks that skip `.hang` media
discovery, so they route around `catalog_format`.
- `doc/bin/cli.md` documents the flags and sinks.
- The media test publishes with `--stats` and plays with `--echo` against a
publisher that solicits feedback. It asserts that the publisher's frame
count matches what was sent and that the viewer's newest arrival advances.
- The gateways build their catalogs in their own crates, so each needs a way
to name the stats track before its first catalog.
- `moq play --echo` reconciles each watched catalog's `echo` section as
`export --echo` does, and refuses `--catalog-format msf` the same way: the
MSF catalog does not carry an `echo` solicitation.
- The media test captures with `--stats` and plays with `--echo` against a
publisher that solicits feedback. It asserts the encoder's frame count and
that the player's newest arrival advances.
- Decided 2026-10-08: no E2EE refusal here. The CLI has no encrypted
broadcast yet, so [Encrypted native CLI](/quest/m2/e2ee/cli.md) refuses `--stats` and
`--echo` when it adds one: they would publish rendition IDs and per-track
counters in plaintext beside it.

## Related

- [TS health stats](/quest/m2/ts-health-stats.md) - adds TS-specific counters after this generic reporting plumbing lands
- [TS health stats](/quest/m2/ts-health-stats.md) - adds TS-specific counters to the reports
- [Encrypted native CLI](/quest/m2/e2ee/cli.md) - refuses stats and feedback on an encrypted broadcast
4 changes: 2 additions & 2 deletions quest/m3/msf-publish-tracks.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ consumer asks.

Raised in [#5360](https://github.com/moq-dev/moq/issues/5360). Until then the
[media stats schema](/doc/concept/stats.md#media-reports) leaves both sections out of
the MSF catalog. The [Rust reporters](/quest/m2/stats/rust.md) quest refuses a
viewer's `--echo` under `--catalog-format msf` when it adds that flag
the MSF catalog. `moq export --echo` refuses `--catalog-format msf`, and the
[Rust reporters](/quest/m2/stats/rust.md) quest's `moq play --echo` will
(decided 2026-10-12).

Open: how the entry names the hang payload, since MSF fixes its packaging
Expand Down
4 changes: 2 additions & 2 deletions rs/moq-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ moq-audio = { workspace = true, optional = true, features = ["aac"] }
moq-auth = { workspace = true, features = ["serve"] }
# `server` enables the HTTP egress server for `moq export hls`; the importer is always available.
moq-hls = { workspace = true, features = ["server"] }
# Writes and reads the stats and feedback snapshots.
moq-json = { workspace = true }
# Names the MSF catalog track `export archive` records.
moq-msf = { workspace = true }
moq-mux = { workspace = true }
Expand Down Expand Up @@ -139,8 +141,6 @@ winit = { version = "0.30.13", optional = true }
sd-notify = { workspace = true }

[dev-dependencies]
# Decodes the timeline the archive round-trip test checks.
moq-json = { path = "../moq-json" }
# `test_relay` stands up a real relay for the `fetch` and `announced` tests to read through.
moq-relay = { path = "../moq-relay", default-features = false, features = ["test-support"] }
# Writes the PAT and PMTs, CRCs included, of the two-program input `import ts --program all` splits.
Expand Down
140 changes: 138 additions & 2 deletions rs/moq-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,38 @@ impl Invocation {
}

for command in &self.stages {
if let Command::Import(import) = command
&& import.stats
&& let Some(reason) = import.source.stats_refusal()
{
anyhow::bail!("--stats names a track in the catalog the import builds, but {reason}");
}
let Command::Export(export) = command else {
continue;
};
if let Some(sink) = export.sink.report() {
anyhow::ensure!(
export.catalog_format.is_none(),
"`export {sink}` reads no media, so it takes no --catalog-format"
);
anyhow::ensure!(
export.echo.is_none(),
"`export {sink}` reads no media to report on; remove --echo"
);
}
if export.echo.is_some() {
if export.sink.stdout().is_none()
&& let Some(sink) = export.sink.ignores_selection()
{
anyhow::bail!(
"--echo reports what a stdout container export reads, and `export {sink}` is not one"
);
}
anyhow::ensure!(
!matches!(export.catalog_format, Some(CatalogFormatArg::Msf)),
"--echo follows the hang catalog's `echo` section, which the MSF catalog does not carry"
);
}
if let Some(stdout) = export.sink.stdout() {
anyhow::ensure!(
stdout.linger.is_zero() || matches!(stdout.format, SubscribeFormat::Ts),
Expand Down Expand Up @@ -745,6 +774,11 @@ pub struct Import {
#[usage(long = "latency-max", hide = true)]
latency_max: Option<crate::duration::Duration>,

/// Name a stats track in the catalog and fill it with what each track has written, about
/// once a second while subscribed. The stdin imports only.
#[usage(long)]
pub stats: bool,

/// The single source feeding the Origin.
#[usage(subcommand)]
pub source: ImportSource,
Expand Down Expand Up @@ -827,6 +861,22 @@ impl ImportSource {
Self::Ts(_) | Self::Fmp4 | Self::Flv | Self::Hls(_) => None,
}
}

/// Why `--stats` can't report this source, or `None` when it can: the CLI builds the one
/// catalog of a stdin import, whose tracks `moq-mux` counts.
fn stats_refusal(&self) -> Option<&'static str> {
match self {
Self::Ts(args) if args.program == Some(TsProgram::All) => {
Some("`import ts --program all` builds a catalog per program")
}
Self::Hls(_) | Self::Rtmp(_) | Self::Srt(_) | Self::Rtc(_) | Self::Archive(_) => {
Some("the gateway and archive imports build their own catalogs")
}
#[cfg(feature = "capture")]
Self::Capture(_) => Some("`import capture` encodes, and its encoders do not count yet"),
_ => None,
}
}
}

/// The MPEG-TS stdin container: which programs of a multiplex to publish.
Expand Down Expand Up @@ -930,6 +980,12 @@ pub struct Export {
#[usage(flatten)]
pub select: crate::subscribe::SelectArgs,

/// Publish feedback where the broadcast's catalog solicits it: a `<NAME>.echo` broadcast
/// under the catalog's `echo` path, reporting what each rendition this export reads has
/// received. One path segment. The stdout container exports only.
#[usage(long)]
pub echo: Option<crate::report::EchoName>,

/// The single sink draining the Origin.
#[usage(subcommand)]
pub sink: ExportSink,
Expand Down Expand Up @@ -981,6 +1037,10 @@ pub enum ExportSink {
Rtc(crate::rtc::Args),
/// Record the broadcast into an object store until it ends.
Archive(crate::archive::ExportArgs),
/// Write the broadcast's stats snapshots to stdout as JSON lines.
Stats,
/// Write the feedback of every `.echo` broadcast under `--broadcast` to stdout as JSON lines.
Echo,
}

impl ExportSink {
Expand All @@ -994,12 +1054,23 @@ impl ExportSink {
Self::Srt(_) => "srt",
Self::Rtc(_) => "rtc",
Self::Archive(_) => "archive",
Self::Stats => "stats",
Self::Echo => "echo",
})
}

/// Whether this sink writes to stdout (the container formats).
/// The report sink's name: these read no media, so they skip catalog discovery.
fn report(&self) -> Option<&'static str> {
match self {
Self::Stats => Some("stats"),
Self::Echo => Some("echo"),
_ => None,
}
}

/// Whether this sink writes to stdout (the container formats and the report sinks).
pub fn is_stdout(&self) -> bool {
self.stdout().is_some()
self.stdout().is_some() || self.report().is_some()
}

/// The stdout container format and its options, when this sink writes to
Expand Down Expand Up @@ -1395,6 +1466,71 @@ mod tests {
}
}

/// `--stats` goes where the CLI builds the one catalog it names a track in.
#[test]
fn stats_needs_a_catalog_the_cli_builds() {
let import = |source: &[&str]| {
let argv = [
"moq",
"--connect",
"http://relay",
"--broadcast",
"room",
"import",
"--stats",
];
Invocation::try_parse_from(argv.iter().chain(source).copied()).unwrap()
};
for source in [
["ts"].as_slice(),
&["fmp4"],
&["flv"],
&["avc3"],
&["ts", "--program", "1"],
] {
import(source).validate().unwrap();
}
for source in [
["ts", "--program", "all"].as_slice(),
&["hls", "http://origin/master.m3u8"],
&["rtmp", "--listen", "127.0.0.1:1935"],
] {
let err = import(source).validate().unwrap_err().to_string();
assert!(err.contains("--stats"), "{source:?}: {err}");
}
}

/// `--echo` reports what a stdout container export reads from a hang catalog, under one
/// path segment, and the report sinks read no media to report on.
#[test]
fn echo_needs_a_container_export_and_a_hang_catalog() {
for sink in ["ts", "fmp4", "mkv", "flv", "h264", "h265"] {
export(&["--echo", "alice", sink]).unwrap().validate().unwrap();
}
for sink in [["hls", "--listen", "127.0.0.1:8080"].as_slice(), &["stats"], &["echo"]] {
let err = export(&[["--echo", "alice"].as_slice(), sink].concat())
.unwrap()
.validate()
.unwrap_err();
assert!(err.to_string().contains("--echo"), "{sink:?}: {err}");
}
let err = export(&["--echo", "alice", "--catalog-format", "msf", "fmp4"])
.unwrap()
.validate()
.unwrap_err();
assert!(err.to_string().contains("MSF"), "{err}");
for name in ["", "a/b", ".", "..", "*"] {
assert!(export(&["--echo", name, "fmp4"]).is_err(), "`{name}` must be refused");
}
for sink in ["stats", "echo"] {
let err = export(&["--catalog-format", "hang", sink])
.unwrap()
.validate()
.unwrap_err();
assert!(err.to_string().contains("--catalog-format"), "{sink}: {err}");
}
}

#[test]
fn import_ts_takes_a_program_number_or_all() {
// `None` when the command line is refused.
Expand Down
Loading
Loading