Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c95a662
feat(cli): report import stats and export feedback
t0ms Oct 16, 2026
2c66df3
fix(cli): address review on the stats reporters
t0ms Oct 17, 2026
9974c23
feat(mux): report TS health and the export's release clock
t0ms Oct 16, 2026
58ffb52
fix(cli): refuse --stats with --epoch and give feedback an epoch
kixelated Oct 18, 2026
65cf0cf
Merge remote-tracking branch 'origin/main' into pr-5848
kixelated Oct 18, 2026
90c4fbf
chore(cli): format, and name the --echo value
kixelated Oct 18, 2026
eafd7df
Merge remote-tracking branch 'origin/main' into pr-5848
kixelated Oct 18, 2026
c97bc23
fix(cli): attach --stats where the import's pipeline is collected
kixelated Oct 18, 2026
3bbadc0
Merge origin/main into quest/m2/stats/rust
t0ms Oct 20, 2026
39fa8f5
docs(cli): drop --epoch from what refuses --stats
t0ms Oct 20, 2026
8a0af79
Merge quest/m2/stats/rust into quest/m2/ts-health-stats
t0ms Oct 20, 2026
7bc5705
fix(cli): bound what the report sinks hold
t0ms Oct 20, 2026
f38b261
Merge quest/m2/stats/rust into quest/m2/ts-health-stats
t0ms Oct 20, 2026
0f0d474
fix(mux): pin `Class`'s discriminants; note the `Export` move in MIGRATE
t0ms Oct 20, 2026
d3c4b72
fix(cli): report live renditions past the cap; read the root and hidd…
t0ms Oct 20, 2026
9c56ab2
Merge quest/m2/stats/rust into quest/m2/ts-health-stats
t0ms Oct 20, 2026
bf2d693
Merge remote-tracking branch 'origin/main' into takeover-5848
kixelated Oct 20, 2026
4b1952a
fix(cli): evict one ended rendition at the cap and reap drained echo …
kixelated Oct 20, 2026
4430821
Merge quest/m2/stats/rust into quest/m2/ts-health-stats
kixelated Oct 20, 2026
070338f
Merge origin/main into quest/m2/ts-health-stats
kixelated Oct 20, 2026
1bc982c
Merge origin/main into quest/m2/ts-health-stats
kixelated Oct 21, 2026
602fb36
Merge origin/main into quest/m2/ts-health-stats
kixelated Oct 21, 2026
bda2cf4
fix(mux): keep a release span's sign in a typed SignedDuration; size …
t0ms Oct 21, 2026
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
10 changes: 9 additions & 1 deletion doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,8 @@ 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, reading one that ends for 5 s more for its
final report. Either sink stops reading a report that grows past 1 MiB.
final report. Either sink stops reading a report that grows past 8 MiB, twice
the largest a TS import publishes.

```bash
moq ... --broadcast event import --stats ts < feed.ts
Expand All @@ -478,6 +479,13 @@ A report holds 1,024 rendition IDs at most: past that, each new ID replaces
one that has ended.
[Stats](/concept/stats#media-reports) has the schema.

A TS import adds an `mpegts` section: each elementary stream's access units
and quiet time, and the TR 101 290 counters it logs. A TS export adds its
streams' rows and a `release` section: frames dropped late, the margin frames
arrive with, and its clock's offset and drift from the source. A passthrough
reports `release` alone at export and neither at import. See
[MPEG-TS sections](/concept/stats#mpeg-ts-sections).

## Debugging

`RUST_LOG=debug` prints the negotiated version and every subscription.
Expand Down
58 changes: 58 additions & 0 deletions doc/concept/stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,64 @@ 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.

### MPEG-TS sections

A TS import or export adds sections of its own, which
`moq_mux::container::ts::stats::Report` reads. As above, counters are
cumulative and omitted at zero, and a gauge is omitted until it is measured.

`mpegts` holds `streams`, one row per elementary stream keyed by its PID in
decimal, and the stream-wide counters. A row has `track` (the track suffix),
`class` (`audio`, `video`, or `data`; an unknown class reads as `data`),
Comment on lines +48 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move the TS field inventory to reference docs

This new section enumerates the mpegts and release wire shape, including PID-key encoding and individual fields, even though scoped doc/AGENTS.md requires feature docs to leave field lists and wire encodings to API docs or drafts. Keeping a second schema copy here will drift from the serde types, so retain the user-facing monitoring behavior and link to a colocated reference instead.

AGENTS.md reference: doc/AGENTS.md:L4-L6

Useful? React with 👍 / 👎.

`units` (access units delivered), and `quiet`, a gauge: milliseconds of
program clock since its last access unit. Its losses are `resyncs`, bytes
`discarded` scanning for audio sync, `unconfirmed` frames, `damaged` units,
`unplaced` units the program clock could not place, the times the stream was
`refused`, and its shares of `continuityCountError`, `transportError`, and
`ptsError`.

The stream-wide counters count ETSI TR 101 290 checks at that standard's
fixed limits:

| Field | Check |
| --- | --- |
| `tsSyncLoss` | 1.1 `TS_sync_loss` |
| `syncByteError` | 1.2 `Sync_byte_error` |
| `patError` | 1.3 `PAT_error_2` |
| `continuityCountError` | 1.4 `Continuity_count_error` |
| `pmtError` | 1.5 `PMT_error_2` |
| `transportError` | 2.1 `Transport_error` |
| `crcError` | 2.2 `CRC_error`, on the PAT and PMT only |
| `pcrRepetitionError` | 2.3a `PCR_repetition_error` |
| `pcrDiscontinuityIndicatorError` | 2.3b `PCR_discontinuity_indicator_error` |
| `ptsError` | 2.5 `PTS_error` |

An import grades the stream as it arrived. An export builds every header
itself and runs no checks, so its counters stay zero and only its rows'
`units` and `quiet` move. Neither grades the wire a receiver sees downstream.
The PCR checks are graded on PCR values, not arrival: they speak for the
encoder's insertion, since the network re-times arrival, and a forward step
over 100 ms counts as both. 1.6 `PID_error` is covered, more strictly, by each
row's `units` and `quiet`: a dead video path behind a live mux still sends
PCR-only packets on its PID, which a packet count reads as live.
`PCR_accuracy_error` is not measured. No verdict is computed: a sparse stream
such as SCTE-35 is quiet for seconds by design, so an alarm reads counter
rates and `quiet` against its own thresholds.

An export also reports `release`, how it keeps up with the source: what it
drops, trims and never receives, and how its output clock tracks the
source's. `export ts --passthrough` reports it without `mpegts`.

| Field | Meaning |
| --- | --- |
| `dropped` | Frames lost from the output after their stream began: missed their release deadline, or a video track dropped them waiting for its next keyframe. |
| `trimmed` | Frames dropped before their stream's first went out, so the output starts on a whole group led by a keyframe. Nothing is missing for these. |
| `gaps` | Times the source skipped media it will never deliver: a group lost or expired upstream, or a resubscription. A declared break is not one. |
| `margin` | Gauge, milliseconds rounded away from zero: the least time any frame arrived before its deadline over the last second. Negative when one missed, however narrowly. |
| `offset` | Gauge, milliseconds rounded away from zero: how far the output clock sits from the source's plus the delay. Positive holds frames longer than the delay. The clock pulls it shut over ten minutes. |
| `drift` | Gauge, parts per million: the source clock's rate against ours, positive running fast. |
Comment on lines +95 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge State the delay required for clock gauges

With export ts --delay 0, including passthrough, Buffer::queue deliberately skips margin collection and steering never runs, so margin, offset, and drift remain absent forever rather than merely pending a measurement. The new table describes these gauges without that condition, which can make consumers treat a healthy zero-delay export as missing data; state the nonzero-delay requirement alongside the claim.

AGENTS.md reference: doc/AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

| `outOfTolerance` | Measurements that found the source clock past the 30 ppm ISO/IEC 13818-1 allows, which the output cannot follow. |

## Broadcasts

Each node publishes under a prefix, `.stats` by default, which
Expand Down
1 change: 0 additions & 1 deletion quest/m1/ts-reliability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,5 @@ remain with their current quests.
## Related

- [TS over RTP legs](/quest/m2/ts-rtp-egress.md) - TS identity alone is not ST 2022-7, which also needs matching RTP headers
- [TS health counters](/quest/m2/ts-health-stats.md) - ingress and egress evidence need distinct monitoring points
- [MSFTS mapping](/quest/m3/msfts-convergence.md) - document the exact equivalence and differences between the carriage modes

1 change: 0 additions & 1 deletion quest/m2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ ranked just below deterministic TS legs, which it requires.
- [Probes resume on the peer](/quest/m2/quic-outage-probe.md) - after an outage, the next probe follows the peer's first packet instead of the backed-off timer
- [Keep-alive from the idle timeout](/quest/m2/quic-keep-alive.md) - the keep-alive interval defaults to a fraction of the negotiated idle timeout; an explicit `quic.keep_alive` overrides
- [Socket close](/quest/m2/quic-socket-close.md) - moq-tokio's QUIC endpoint releases its socket on close, so it drops its wrapper
- [TS health stats](/quest/m2/ts-health-stats.md) - the TS counters ride the stats plumbing beside the media counters
- [Windows.Graphics.Capture](/quest/m2/capture-wgc.md) - the WGC display and window backend verified on real Windows hardware
- [Windows capture parity](/quest/m2/capture-windows.md) - system audio and a settled app-capture policy
- [Linux capture parity](/quest/m2/capture-linux.md) - Wayland window/system-audio capture with explicit display-selection and app-capture limits
Expand Down
3 changes: 1 addition & 2 deletions quest/m2/stats/health.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Guidance, to be settled while building:
starts over rather than going negative.
- Container sections such as `mpegts` are excluded from the verdict; their
counters are read by their own consumers
([TS health counters](/quest/m2/ts-health-stats.md)).
([MPEG-TS sections](/doc/concept/stats.md#mpeg-ts-sections)).
- Thresholds are a documented default the caller can override, not policy
baked into the types. Start from a few measured cases rather than guesses.
- A verdict carries its evidence: which inputs drove it and who observed them.
Expand All @@ -65,4 +65,3 @@ Public API: new health types in `hang` and `@moq/hang`. Wire: none.

- [QoS](/quest/m2/qos/README.md) - the relay's delivery counters, which a combined downstream verdict also reads
- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - a new epoch is one way a counter reset shows
- [TS health counters](/quest/m2/ts-health-stats.md) - container counters kept out of the verdict
1 change: 0 additions & 1 deletion quest/m2/stats/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,4 @@

## Related

- [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
59 changes: 0 additions & 59 deletions quest/m2/ts-health-stats.md

This file was deleted.

1 change: 1 addition & 0 deletions rs/moq-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ async fn run_stdout(
return subscribe.run().await;
};
let mut echo = report::Echo::new(echo, subscribe.source().clone(), catalog);
let subscribe = subscribe.with_sections(echo.sections());
let result = tokio::select! {
result = subscribe.run() => result,
never = echo.run() => match never {},
Expand Down
8 changes: 8 additions & 0 deletions rs/moq-cli/src/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,12 @@ async fn decode(
// they have to surface as they accumulate rather than at exit.
let mut log = ts::stats::Log::default();
let mut sampled = tokio::time::Instant::now();
let sections = report.as_ref().map(crate::report::Stats::sections);
let report_mpegts = |snapshot: &ts::stats::Snapshot| {
if let Some(sections) = &sections {
sections.set(|report| report.mpegts = Some(snapshot.clone()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep generated TS reports below the reader cap

When a TS feed rotates its PMT through enough elementary PIDs, Import::stats retains one row per PID across those routes, and populated health fields can make the mpegts object installed here exceed report.rs's 1 MiB MAX_REPORT. The project's own export stats consumer then returns TooLarge and permanently stops monitoring that otherwise valid producer report; either bound/evict the reported PID rows below that limit or choose a bounded reader limit that covers every report this producer can generate.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bda2cf4 by sizing the cap to what this producer can publish rather than evicting rows. An import keeps one row per PID, and PIDs are 13 bits, so a report holds at most 8,192 rows. the_largest_ts_report_reads_whole builds that worst case (every PID, every counter and field at its limit, 1,024 renditions) and reads it back through moq_json::snapshot: 3.48 MB, which the old 1 MiB cap refused as TooLarge. MAX_REPORT is now 8 MiB, over twice that, and doc/bin/cli.md states the limit.

(Written by Claude Opus 5.5)

}
};

// Run the read/decode loop so an error surfaces here rather than
// dropping the decoder (and its tracks) with a bare Error::Dropped.
Expand All @@ -626,6 +632,7 @@ async fn decode(
if sampled.elapsed() >= ts::stats::Log::INTERVAL {
sampled = tokio::time::Instant::now();
if let Some(stats) = decoder.stats() {
report_mpegts(&stats);
log.sample(stats);
}
}
Expand Down Expand Up @@ -656,6 +663,7 @@ async fn decode(
// The drain at end of input can publish a frame nothing vouched for, so the
// final snapshot is only complete after `finish`.
if let Some(stats) = decoder.stats() {
report_mpegts(&stats);
log.finish(&stats);
}
match outcome {
Expand Down
Loading