Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
69c946e
chore(quest): claim quest/m1/stats-group-tracks.md
kixelated Oct 23, 2026
ff8adb3
feat(stats): publish groups as tracks of one node broadcast
kixelated Oct 23, 2026
3f14437
refactor(stats): tidy the track layout's default-tier setup and pin i…
kixelated Oct 23, 2026
0a5e977
Merge origin/main into quest/m1/stats-group-tracks
kixelated Oct 23, 2026
498613e
Merge origin/main into quest/m1/stats-group-tracks
kixelated Oct 23, 2026
e510cbe
fix(net): forget a track name once its track is gone
kixelated Oct 23, 2026
c099d1e
test(net): drop an unused mut
kixelated Oct 23, 2026
8b7a8d6
Merge origin/main into quest/m1/stats-group-tracks
kixelated Oct 23, 2026
798dead
style(stats): rustfmt the merged imports
kixelated Oct 23, 2026
4ef184e
feat(stats): number groups by their publish interval
kixelated Oct 23, 2026
23d7d8c
Merge origin/main into quest/m1/stats-group-tracks
kixelated Oct 23, 2026
3b7708e
style(stats): rustfmt
kixelated Oct 23, 2026
1a9999c
fix(stats): number and stamp groups from the drain's instant
kixelated Oct 23, 2026
1879eae
Merge remote-tracking branch 'origin/main' into quest/m1/stats-group-…
kixelated Oct 23, 2026
7032f41
test(stats): a finished group track is unlisted too
kixelated Oct 23, 2026
c4f17ae
fix(stats): number each drain by its scheduled tick
kixelated Oct 23, 2026
637cea1
docs: drop stale predecessor-sequence claims
kixelated Oct 23, 2026
543efdd
fix(transcode): finish a rung only when its source finished
kixelated Oct 23, 2026
a9e703e
Merge remote-tracking branch 'origin/main' into quest/m1/stats-group-…
kixelated Oct 23, 2026
a14cdac
fix(stats): number each drain's interval at full resolution
kixelated Oct 23, 2026
124d709
Merge origin/main into quest/m1/stats-group-tracks
kixelated Oct 25, 2026
91abf34
feat(stats)!: make group tracks the only stats layout
kixelated Oct 25, 2026
6b42614
Merge remote-tracking branch 'origin/main' into worktree-agent-a607fe…
kixelated Oct 25, 2026
30c4192
bench(stats): decode a group track that reopens past its byte cap
kixelated Oct 25, 2026
ba9036a
Merge remote-tracking branch 'origin/main' into worktree-agent-a607fe…
kixelated Oct 25, 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion demo/relay/localhost.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public = "**"
#
# .stats/node/local/host
#
# carrying totals.json, publisher.json, subscriber.json, and sessions.json tracks. See
# carrying totals.json and group.json tracks. See
# doc/bin/relay/config.md for the field semantics.
[stats]
enabled = true
Expand Down
82 changes: 50 additions & 32 deletions demo/web/src/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,24 @@
* let you drill into one. A restarted relay announces a new epoch at the same
* path, which arrives as an end then a start, so its history starts over.
*
* Per-node tracks we read:
* publisher.json egress (relay -> downstream viewers)
* subscriber.json ingress (upstream publishers -> relay)
* sessions.json sessions by stats label
* Per node we read `group.json`, the whole node's group at the relay's default
* `depth = 0`: `{ epoch, tiers: { <tier>: { paths, labels } } }`, a JSON snapshot
* with merge-patch deltas. From the default tier we take each path's `publisher`
* (egress, relay -> downstream viewers) and `subscriber` (ingress, upstream
* publishers -> relay), and each stats label's sessions.
*
* Each frame is `{ "<broadcast path>": Snapshot }`. Counters are cumulative;
* "active" = started - ended. The relay includes every entry it still holds
* counters for, idle ones too, so the latest frame is a snapshot of now. We sample the aggregate on an interval
* to derive per-second throughput rates for the charts. A relay built after the
* started/ended rename still writes the legacy names beside the new ones; this
* dashboard prefers the canonical spelling and falls back so it also reads an
* older relay.
* Counters are cumulative; "active" = started - ended. The relay includes every
* entry it still holds counters for, idle ones too, so the latest frame is a
* snapshot of now. We sample the aggregate on an interval to derive per-second
* throughput rates for the charts. A relay built after the started/ended rename
* still writes the legacy names beside the new ones; this dashboard prefers the
* canonical spelling and falls back so it also reads an older relay.
*/

import "./highlight";
import { Milli, Nano, wall } from "@moq/clock";
import { Net, Signals } from "@moq/hang";
import * as Json from "@moq/json";

const RELAY_URL = import.meta.env.VITE_RELAY_URL ?? "http://localhost:4443";

Expand Down Expand Up @@ -78,9 +79,32 @@ interface SessionCounters {
type SessionFrame = Record<string, SessionCounters>;

interface NodeStats {
egress: BroadcastFrame; // publisher.json
ingress: BroadcastFrame; // subscriber.json
sessions: SessionFrame; // sessions.json
egress: BroadcastFrame; // each path's `publisher`
ingress: BroadcastFrame; // each path's `subscriber`
sessions: SessionFrame; // `labels`
}

// One tier of a `group.json` frame.
interface GroupTier {
paths?: Record<string, { publisher?: Snapshot; subscriber?: Snapshot }>;
labels?: SessionFrame;
}

interface GroupFrame {
tiers?: Record<string, GroupTier>;
}

// The default tier's egress, ingress, and sessions from a group frame.
function nodeStatsOf(frame: GroupFrame): NodeStats {
const tier = frame.tiers?.[""] ?? {};
const egress: BroadcastFrame = {};
const ingress: BroadcastFrame = {};
for (const [path, usage] of Object.entries(tier.paths ?? {})) {
// The relay leaves out a side that never moved.
if (usage.publisher) egress[path] = usage.publisher;
if (usage.subscriber) ingress[path] = usage.subscriber;
}
return { egress, ingress, sessions: tier.labels ?? {} };
}

// Canonical spelling wins when a frame carries both names.
Expand Down Expand Up @@ -176,24 +200,18 @@ function subscribeNode(effect: Signals.Effect, origin: Net.Origin.Table, path: N
const consumer = request.active.peek();
if (!consumer) return;

const sub = <K extends keyof NodeStats>(trackName: string, key: K) => {
const track = consumer.track(trackName).subscribe().ordered();
effect.cleanup(() => track.close());
effect.spawn(async () => {
for (;;) {
const data = await effect.race(track.readJson());
if (data === undefined) break;
nodeStats.mutate((s) => {
const cur = s[node];
if (cur) cur[key] = (data ?? {}) as NodeStats[K];
});
}
});
};

sub("publisher.json", "egress");
sub("subscriber.json", "ingress");
sub("sessions.json", "sessions");
const track = consumer.track("group.json").subscribe();
effect.cleanup(() => track.close());
const group = new Json.Snapshot.Consumer<GroupFrame>({ track });
effect.spawn(async () => {
for (;;) {
const state = await effect.race(group.latest());
if (state === undefined) break;
nodeStats.mutate((s) => {
if (s[node]) s[node] = nodeStatsOf(state.value ?? {});
});
}
});
}

// ---- Aggregation ----------------------------------------------------------
Expand Down
17 changes: 9 additions & 8 deletions doc/bin/inspect.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,16 @@ Dial the `.stats` path, which the token must cover:

```bash
moq --connect http://localhost:4443/.stats announced
moq --connect http://localhost:4443/.stats --broadcast node/local/host fetch publisher.json | jq
curl http://localhost:4443/fetch/.stats/node/local/host/publisher.json | jq
moq --connect http://localhost:4443/.stats --broadcast node/local/host fetch totals.json | jq
curl http://localhost:4443/fetch/.stats/node/local/host/totals.json | jq
```

The node name (`local/host` here) is `stats.node`. Each fetch returns the
newest snapshot of cumulative counters. `totals.json` sums each tier's traffic
and sessions, `publisher.json` is egress per broadcast path,
`subscriber.json` is ingress, and `sessions.json` counts sessions per stats
label. A subscriber may also ask for `<path>/prefix.json`, one broadcast
prefix rolled up with each child, produced only while held. Fetch twice and divide by the interval for a
rate. The `.json.z` twins carry compressed patches, which these tools print as
raw bytes. [Stats](/concept/stats) describes every field.
and sessions. `group.json` (`<group>/group.json` at `stats.depth` 1 or more)
carries a group's traffic per broadcast path and sessions per stats label: its
newest group is a full snapshot followed by merge-patch deltas, one per line.
A subscriber may also ask for `<path>/prefix.json`, one broadcast prefix rolled
up with each child, produced only while held. Fetch twice and divide by the
interval for a rate. The `.json.z` twins carry compressed patches, which these
tools print as raw bytes. [Stats](/concept/stats) describes every field.
16 changes: 9 additions & 7 deletions doc/bin/relay/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,20 +350,22 @@ group of every track is always kept, even past `capacity`.
```toml
[stats]
enabled = true
prefix = ".stats" # Broadcasts appear under <prefix>/node/<node>.
prefix = ".stats" # The broadcast appears at <prefix>/node/<node>.
interval = "1s" # Time between snapshots. Default.
node = "sjc/1" # Disambiguates relays sharing a cluster.
depth = 1 # Also bucket by the first N path segments (per tenant).
linger = "5m" # Keep an empty group's broadcast announced this long. Default.
depth = 1 # One group track per first N path segments (per tenant).
linger = "5m" # Keep an empty group's track listed this long. Default.
```

Each node publishes its traffic and session counters as MoQ tracks, split by a
**tier** label from the auth server's grant (`--cluster-tier` for links this
relay dials and LAN peers it admits), which is what makes billing per customer or per region possible.
Each run, and each group returning after its linger, announces under a fresh
[epoch](/concept/moq-lite#publisher-epochs), so a restart is a new broadcast at
the same path. Each group's totals count everything since its epoch began, so
billing reads those; per-path traffic and per-label sessions and HTTP requests are for
Each run announces under a fresh [epoch](/concept/moq-lite#publisher-epochs),
so a restart is a new broadcast at the same path. Each group is a
[track](/concept/stats#group-tracks) of that broadcast, and one returning after
its linger restarts its counters under a new epoch without an announce. The
node's totals count everything since its epoch began, so billing reads those;
a group's per-path traffic and per-label sessions and HTTP requests are for
dashboards. Session counters report how close sessions come to the
per-session limits, past which one is closed with `TOO_MANY_REQUESTS`.
[Stats](/concept/stats) describes the paths, tracks, and encodings; read them
Expand Down
Loading
Loading