Skip to content

Commit dbbc466

Browse files
afrindclaude
andcommitted
stats: per-track counters exposed at /metrics/track
Per-(track, iothread) TrackStats live in the stats tier, owned by a TrackStatsRegistry that binds one TrackStatsCollector per data-plane thread and merges them on demand — the same split as StatsRegistry/MoQStatsCollector. A TrackStatsFilter counts on the relay ingest chain and on each downstream subscriber, including the PUBLISH fan-out path. No read path dereferences a registry forwarder, so counting behaves identically in single-thread, relay-exec and local-forwarder modes. GET /metrics/track?service=&namespace=&track=&limit= reports subscribers, groups/subgroups/objects/datagrams/bytes in both directions, and publish-start/last-object timestamps. Rates are left to Prometheus. A match wider than the limit is rejected rather than truncated: an arbitrary F14-ordered subset would reshuffle between scrapes. Namespaces and track names are safeName()-encoded in labels and expected in the same form on the query, so a scraped value pastes back into a query and two tracks cannot collapse onto one label set. Groups are counted against an LRU window of the 3 most recent group IDs, so subgroups of concurrently-open groups do not inflate the count. Name matching uses forEachName rather than forEach: EntryView copies a forwarder and session shared_ptr per entry, ~72% of the walk at 100k tracks (13.7ms vs 3.9ms). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 059343c commit dbbc466

29 files changed

Lines changed: 2222 additions & 22 deletions

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,10 @@ add_library(moqx_core STATIC
156156
src/admin/CachePurgeHandler.cpp
157157
src/admin/ConfigHandler.cpp
158158
src/admin/MetricsHandler.cpp
159+
src/admin/TrackMetricsHandler.cpp
159160
src/admin/StateHandler.cpp
160161
src/stats/StatsRegistry.cpp
162+
src/stats/TrackStatsRegistry.cpp
161163
src/stats/MoQStatsCollector.cpp
162164
src/stats/PicoQuicStatsCollector.cpp
163165
src/stats/QuicStatsCollector.cpp
@@ -166,6 +168,7 @@ add_library(moqx_core STATIC
166168
src/SafeTrackName.cpp
167169
src/relay/AuthFilters.cpp
168170
src/relay/TopNFilter.cpp
171+
src/relay/TrackStatsFilter.cpp
169172
src/relay/PropertyRanking.cpp
170173
src/relay/CrossExecFilter.cpp
171174
src/relay/CrossExecForwarderCallback.cpp

docs/config.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,16 +432,30 @@ admin:
432432
# cert_file: /etc/moqx/admin-cert.pem
433433
# key_file: /etc/moqx/admin-key.pem
434434
# alpn: [h2, "http/1.1"]
435+
# track_metrics_enabled: true # per-track counting and /metrics/track
436+
# track_metrics_limit: 10 # default tracks per /metrics/track scrape
437+
# track_metrics_max_limit: 1000 # ceiling on the ?limit= parameter
435438
```
436439

437440
Either `plaintext: true` or a `tls` block must be set, but not both.
438441

442+
`track_metrics_enabled: false` leaves the counting filters out of the data path
443+
entirely — nothing is installed, so there is no per-object cost — and
444+
`/metrics/track` answers `503` rather than an empty scrape that would read as
445+
"no live tracks".
446+
447+
`track_metrics_limit` and `track_metrics_max_limit` bound `/metrics/track`. The
448+
limit is a guard rail, not a selection rule: a query matching more tracks than
449+
the limit is rejected rather than truncated, because an arbitrary subset would
450+
give Prometheus a series set that reshuffles between scrapes.
451+
439452
### Endpoints
440453

441454
| Method | Path | Description |
442455
|---|---|---|
443456
| `GET` | `/info` | Returns `{"service":"moqx","version":"..."}`. |
444457
| `GET` | `/metrics` | Prometheus-format metrics. See [docs/metrics.md](metrics.md) (pending PR #137). |
458+
| `GET` | `/metrics/track` | Per-track Prometheus metrics for live tracks. See [docs/metrics.md](metrics.md). |
445459
| `GET` | `/state` | Relay state: connected peers, active subscriptions, namespace tree, and cache stats. Pending PR #146. |
446460

447461
---

docs/metrics.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,83 @@ Counters with per-code breakdowns:
182182
|--------|-------------|
183183
| `moqx_quicActiveConnections` | Active QUIC connections |
184184
| `moqx_quicActiveStreams` | Active QUIC streams across all connections |
185+
186+
## Per-Track Metrics
187+
188+
```
189+
GET /metrics/track?service=<name>&namespace=<a/b>&track=<name>&limit=<N>
190+
```
191+
192+
Requires `admin.track_metrics_enabled` (default true); when it is false the
193+
counting filters are never installed and this endpoint returns `503`.
194+
195+
Reports counters for **live tracks** — a track's series disappear when the track
196+
goes away, and start from zero if it comes back.
197+
198+
| Parameter | Required | Description |
199+
|---|---|---|
200+
| `namespace` | yes | Namespace prefix in the safe form below. Matches every track under the prefix unless `track` is given. |
201+
| `service` | no | Restrict to one service. Default: all services, each labeled. |
202+
| `track` | no | Exact track name within the namespace. |
203+
| `limit` | no | Max tracks to report. Default `admin.track_metrics_limit` (10), clamped to `admin.track_metrics_max_limit` (1000). |
204+
205+
`limit` is a sanity guard, not a top-N selector. A query matching more tracks
206+
than the limit returns **400** with the match count rather than truncating —
207+
an arbitrary subset would give Prometheus a series set that reshuffles between
208+
scrapes, producing gap-filled graphs that look like data. Narrow the namespace
209+
or raise the limit. A query that matches nothing returns 200 with no series.
210+
211+
Every series carries `{service, namespace, track}` labels.
212+
213+
### Name encoding
214+
215+
Namespaces and track names are arbitrary bytes, so the `namespace` and `track`
216+
values — both in labels and in the query parameters — use the form RECOMMENDED
217+
by moq-transport, [Representing Namespace and Track
218+
Names](https://datatracker.ietf.org/doc/html/draft-ietf-moq-transport#name-representing-namespace-and-t).
219+
The namespace `conf.example.com` / `room 1` renders as
220+
`conf.2eexample.2ecom-room.201`.
221+
222+
Because the encoding is injective, two tracks can never collapse onto one label
223+
set and produce duplicate series, and a scraped label value can be pasted
224+
straight back into a query. Values outside the form — an unencoded `/` or space,
225+
say — are rejected with 400 rather than silently matching something else.
226+
227+
| Metric | Type | Description |
228+
|--------|------|-------------|
229+
| `moqx_track_groups_received_total` | counter | Groups ingested |
230+
| `moqx_track_subgroups_received_total` | counter | Subgroups ingested |
231+
| `moqx_track_objects_received_total` | counter | Objects ingested |
232+
| `moqx_track_datagrams_received_total` | counter | Objects ingested as datagrams (also counted in objects) |
233+
| `moqx_track_bytes_received_total` | counter | Object payload bytes ingested |
234+
| `moqx_track_groups_sent_total` | counter | Groups delivered, summed over subscribers |
235+
| `moqx_track_subgroups_sent_total` | counter | Subgroups delivered, summed over subscribers |
236+
| `moqx_track_objects_sent_total` | counter | Objects delivered, summed over subscribers |
237+
| `moqx_track_datagrams_sent_total` | counter | Objects delivered as datagrams, summed over subscribers |
238+
| `moqx_track_bytes_sent_total` | counter | Object payload bytes delivered, summed over subscribers |
239+
| `moqx_track_subscribers` | gauge | Current downstream subscribers |
240+
| `moqx_track_publish_start_timestamp_seconds` | gauge | Unix time the relay first saw the track |
241+
| `moqx_track_last_object_timestamp_seconds` | gauge | Unix time of the most recent ingested object |
242+
243+
Group counters track the 3 most recently seen group IDs (LRU), so subgroups of
244+
concurrently-open groups can arrive interleaved without inflating the count. A
245+
group revisited after 3 *other* groups have been seen is counted again — the
246+
window bounds per-filter state, and MoQ group IDs advance, so this only shows up
247+
in pathological interleaving.
248+
249+
Byte counters measure **object payload bytes**, not wire bytes: no MOQT headers,
250+
no QUIC or transport framing. For wire-level volume use `moqx_quicBytesRead_total`
251+
and `moqx_quicBytesWritten_total`.
252+
253+
Sent counters are summed across subscribers, so one ingested object fanned out to
254+
three subscribers increments `objects_sent` by 3.
255+
256+
### Bitrate and object rate
257+
258+
Rates are not computed server-side — derive them from the counters:
259+
260+
```promql
261+
rate(moqx_track_bytes_received_total[1m]) * 8 # ingest bits/sec
262+
rate(moqx_track_objects_received_total[1m]) # ingest objects/sec
263+
rate(moqx_track_bytes_sent_total[1m]) * 8 # egress bits/sec (all subscribers)
264+
```

src/MoqxRelay.cpp

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
#include "relay/NullConsumers.h"
1414
#include "relay/PublisherCrossExecFilter.h"
1515
#include "relay/SubscriberCrossExecFilter.h"
16+
#include "relay/TrackStatsFilter.h"
1617
#include "relay/WeakRelayForwarderCallback.h"
1718
#include <folly/container/F14Set.h>
19+
#include <folly/coro/Collect.h>
1820
#include <moxygen/MoQFilters.h>
1921
#include <moxygen/MoQTrackProperties.h>
2022

@@ -550,9 +552,9 @@ folly::coro::Task<folly::Expected<PublishOk, PublishError>> MoqxRelay::registerP
550552
}
551553

552554
auto topNView = registry_.getTopNView(ftn);
553-
XCHECK(topNView && topNView->topNFilter)
554-
<< "registerPublishOnRelayExec: topNFilter always present in MT mode";
555-
relayChainFilter->setDownstream(topNView->topNFilter);
555+
XCHECK(topNView && topNView->chainHead)
556+
<< "registerPublishOnRelayExec: relay chain always present in MT mode";
557+
relayChainFilter->setDownstream(topNView->chainHead);
556558

557559
co_return setup.value().publishOk;
558560
}
@@ -798,7 +800,12 @@ std::optional<MoqxRelay::PreparedPublish> MoqxRelay::startPublish(
798800
subscriber->unsubscribe();
799801
return std::nullopt;
800802
}
801-
subscriber->trackConsumer = std::move(pub->consumer);
803+
subscriber->trackConsumer = wrapWithTrackStats(
804+
trackStats_,
805+
forwarder->fullTrackName(),
806+
std::move(pub->consumer),
807+
stats::TrackDirection::Egress
808+
);
802809
return PreparedPublish{std::move(subscriber), std::move(pub->reply)};
803810
}
804811

@@ -1339,7 +1346,13 @@ MoqxRelay::buildFilterChain(const FullTrackName& ftn, std::shared_ptr<MoQForward
13391346
topNFilter->setActivityThreshold(activityThreshold_);
13401347
return SubscriptionRegistry::FilterChainResult{
13411348
.consumer = std::static_pointer_cast<TrackConsumer>(forwarder),
1342-
.topNFilter = topNFilter
1349+
.topNFilter = topNFilter,
1350+
.chainHead = wrapWithTrackStats(
1351+
trackStats_,
1352+
ftn,
1353+
std::static_pointer_cast<TrackConsumer>(topNFilter),
1354+
stats::TrackDirection::Ingest
1355+
)
13431356
};
13441357
}
13451358

@@ -1361,9 +1374,16 @@ MoqxRelay::buildFilterChain(const FullTrackName& ftn, std::shared_ptr<MoQForward
13611374
auto topNFilter =
13621375
std::make_shared<TopNFilter>(ftn, std::static_pointer_cast<TrackConsumer>(terminationFilter));
13631376
topNFilter->setActivityThreshold(activityThreshold_);
1377+
auto chainHead = wrapWithTrackStats(
1378+
trackStats_,
1379+
ftn,
1380+
std::static_pointer_cast<TrackConsumer>(topNFilter),
1381+
stats::TrackDirection::Ingest
1382+
);
13641383
return SubscriptionRegistry::FilterChainResult{
1365-
.consumer = std::static_pointer_cast<TrackConsumer>(topNFilter),
1366-
.topNFilter = topNFilter
1384+
.consumer = chainHead,
1385+
.topNFilter = topNFilter,
1386+
.chainHead = chainHead
13671387
};
13681388
}
13691389

@@ -1847,12 +1867,11 @@ folly::coro::Task<MoqxRelay::PublisherAttachment> MoqxRelay::attachNewLocalForwa
18471867
}
18481868
auto upstreamOk = std::move(upstreamResult->value());
18491869

1850-
// Wire the relay chain to topNFilter before pending.complete() so buffered objects
1851-
// see the filter.
1870+
// Wire the relay chain before pending.complete() so buffered objects see the filters.
18521871
if (relayChainFilter) {
18531872
auto topNView = registry_.getTopNView(ftn);
1854-
if (topNView && topNView->topNFilter) {
1855-
relayChainFilter->setDownstream(topNView->topNFilter);
1873+
if (topNView && topNView->chainHead) {
1874+
relayChainFilter->setDownstream(topNView->chainHead);
18561875
}
18571876
}
18581877

@@ -1951,6 +1970,9 @@ folly::coro::Task<Publisher::SubscribeResult> MoqxRelay::subscribeFromSubscriber
19511970
auto [localFwd, isNew, localReg] =
19521971
acquireLocalForwarder(ftn, [&] { return std::make_shared<MoQForwarder>(ftn); });
19531972

1973+
consumer =
1974+
wrapWithTrackStats(trackStats_, ftn, std::move(consumer), stats::TrackDirection::Egress);
1975+
19541976
if (!isNew) {
19551977
if (auto err = checkRangeNotInPast(*localFwd, subReq)) {
19561978
co_return folly::makeUnexpected(std::move(*err));
@@ -2062,6 +2084,9 @@ MoqxRelay::subscribeImpl(SubscribeRequest subReq, std::shared_ptr<TrackConsumer>
20622084
co_await upstream_->waitForConnected(kUpstreamConnectWaitTimeout);
20632085
}
20642086

2087+
consumer =
2088+
wrapWithTrackStats(trackStats_, ftn, std::move(consumer), stats::TrackDirection::Egress);
2089+
20652090
auto firstOrSubsequent = registry_.getOrCreateFromSubscribe(
20662091
ftn,
20672092
shared_from_this(),
@@ -2584,6 +2609,27 @@ void MoqxRelay::onTrackEvicted(const FullTrackName& ftn, std::shared_ptr<MoQSess
25842609
evict(registry_.getForwarder(ftn));
25852610
}
25862611

2612+
MoqxRelay::TrackMatch
2613+
MoqxRelay::matchTracks(const TrackNamespace& nsPrefix, const std::string* trackName, size_t limit)
2614+
const {
2615+
TrackMatch match;
2616+
// forEachName, not forEach: EntryView copies two shared_ptrs per entry, which
2617+
// dominates the walk (measured ~72% of it at 100k tracks) and this needs none.
2618+
registry_.forEachName([&](const FullTrackName& ftn) {
2619+
if (!ftn.trackNamespace.startsWith(nsPrefix)) {
2620+
return;
2621+
}
2622+
if (trackName && ftn.trackName != *trackName) {
2623+
return;
2624+
}
2625+
++match.matched;
2626+
if (match.keys.size() < limit) {
2627+
match.keys.push_back(ftn);
2628+
}
2629+
});
2630+
return match;
2631+
}
2632+
25872633
void MoqxRelay::dumpState(RelayStateVisitor& visitor) const {
25882634
visitor.onPeersBegin();
25892635
for (const auto& [sess, peer] : peerSubNsHandles_) {

src/MoqxRelay.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#include "relay/LocalForwarderRegistry.h"
1717
#include "relay/PropertyRanking.h"
1818
#include "relay/RelayExecUtil.h"
19+
#include "stats/TrackStatsRegistry.h"
1920
#include <moxygen/MoQSession.h>
2021
#include <moxygen/relay/MoQForwarder.h>
2122

@@ -130,6 +131,22 @@ class MoqxRelay : public moxygen::Publisher,
130131

131132
folly::Executor* getRelayExec() const { return relayExec_; }
132133

134+
// execs must cover every thread the data plane runs on (io threads plus
135+
// relayExec_).
136+
stats::TrackStatsRegistry& trackStatsRegistry() { return trackStats_; }
137+
const stats::TrackStatsRegistry& trackStatsRegistry() const { return trackStats_; }
138+
139+
struct TrackMatch {
140+
std::vector<moxygen::FullTrackName> keys;
141+
// Total matches before the limit was applied.
142+
size_t matched{0};
143+
};
144+
145+
// Must run on the relay exec.
146+
TrackMatch
147+
matchTracks(const moxygen::TrackNamespace& nsPrefix, const std::string* trackName, size_t limit)
148+
const;
149+
133150
void setAllowedNamespacePrefix(moxygen::TrackNamespace allowed) {
134151
allowedNamespacePrefix_ = std::move(allowed);
135152
}
@@ -582,6 +599,9 @@ class MoqxRelay : public moxygen::Publisher,
582599

583600
bool useLocalForwarders_{false};
584601
folly::ThreadLocalPtr<LocalForwarderRegistry> tlForwarders_;
602+
603+
stats::TrackStatsRegistry trackStats_;
604+
585605
std::unique_ptr<MoqxCache> cache_;
586606
uint64_t maxDeselected_{kDefaultMaxDeselected};
587607

0 commit comments

Comments
 (0)