|
| 1 | +# Plan: Stats Collection and Admin Endpoint for o-rly |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +o-rly is a MoQ relay built on moxygen. The codebase is fully functional: |
| 6 | +- `ORelay` (`include/o_rly/ORelay.h`, `src/ORelay.cpp`) — relay logic |
| 7 | +- `ORelayServer` (`include/o_rly/ORelayServer.h`, `src/ORelayServer.cpp`) — subclasses `moxygen::MoQServer`; `onNewSession()` / `terminateClientSession()` are the session lifecycle hooks |
| 8 | +- `main.cpp` — full `folly::Init`, flags, `evb.loopForever()` |
| 9 | + |
| 10 | +moxygen hooks already exist — **no moxygen changes required**: |
| 11 | +- `MoQSession::setPublisherStatsCallback()` / `setSubscriberStatsCallback()` (`MoQSession.h:260-267`) |
| 12 | +- `MoQServer::setQuicStatsFactory()` (`MoQServer.h:57`) |
| 13 | + |
| 14 | +Admin server uses `proxygen/httpserver/HTTPServer.h` (classic RequestHandler API) for now; future migration to `proxygen/lib/http/coro/server/HTTPServer.h`. |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Threading Model for Stats |
| 19 | + |
| 20 | +The relay is currently **single-threaded** (one event loop). Key facts: |
| 21 | +- **MoQ stats callbacks** fire on the relay's executor — single producer; plain counters, no synchronization on writes. |
| 22 | +- **QUIC stats callbacks** fire on QUIC worker threads. mvfst creates **one `QuicTransportStatsCallback` instance per worker** via the factory — each is also single-producer. |
| 23 | +- **HTTP reader** is on the admin server thread — aggregates across collectors on-demand. |
| 24 | + |
| 25 | +**Write path (hot): plain counter increment — zero overhead, single `ADD` instruction.** |
| 26 | + |
| 27 | +**Read path (cold, Prometheus scrapes every 15-60s): folly::coro on-demand aggregation.** |
| 28 | + |
| 29 | +Each collector holds a `folly::Executor::KeepAlive<>` to its owning executor. `StatsRegistry::aggregateAsync()` is a `folly::coro::Task<StatsSnapshot>`: |
| 30 | +1. Briefly locks registry mutex to copy the collector list; releases. |
| 31 | +2. Builds one `folly::coro::Task<StatsSnapshot>` per collector, each `scheduleOn(collector->owningExecutor())`. The task calls `collector->snapshot()` — runs on the collector's own thread, no data race, no atomics. |
| 32 | +3. `co_await folly::coro::collectAll(std::move(tasks))` — parallel fan-out/fan-in. |
| 33 | +4. Sums snapshots via `StatsSnapshot::operator+=` and returns. |
| 34 | + |
| 35 | +Registry mutex is **only** for the collector list (adds/removes at session connect/disconnect). |
| 36 | + |
| 37 | +**Caching:** `AdminServer` caches the last `StatsSnapshot` and its timestamp. On each scrape, if the cached snapshot is fresh enough (configurable TTL, e.g. 5s), it returns the cached copy without re-aggregating. A `?nocache=1` query param bypasses the cache. This prevents hammering the aggregation if a scraper misbehaves. |
| 38 | + |
| 39 | +`MetricsHandler::onEOM()` sketch: |
| 40 | +```cpp |
| 41 | +void onEOM() noexcept override { |
| 42 | + folly::coro::co_invoke([this]() -> folly::coro::Task<void> { |
| 43 | + auto snapshot = co_await registry_->aggregateAsync(); // or cached |
| 44 | + auto text = StatsSnapshot::formatPrometheus(snapshot); |
| 45 | + ResponseBuilder(downstream_) |
| 46 | + .status(200, "OK") |
| 47 | + .header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") |
| 48 | + .body(std::move(text)) |
| 49 | + .sendWithEOM(); |
| 50 | + }) |
| 51 | + .scheduleOn(evb_) |
| 52 | + .start(); |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +## StatsCollectorBase and StatsSnapshot |
| 59 | + |
| 60 | +### X-macro pattern for StatsSnapshot |
| 61 | + |
| 62 | +All scalar fields and all histograms are defined via X-macros. Adding a new counter, gauge, or histogram is **one line** in the corresponding macro; struct declaration, `operator+=`, and `formatPrometheus()` all follow automatically. |
| 63 | + |
| 64 | +```cpp |
| 65 | +// Scalars: uint64_t for counts (can't be negative), int64_t for gauges (negative = bug) |
| 66 | +#define STATS_COUNTER_FIELDS(X) \ |
| 67 | + X(uint64_t, moqSubscribeSuccess) \ |
| 68 | + X(uint64_t, moqSubscribeError) \ |
| 69 | + X(uint64_t, moqFetchSuccess) \ |
| 70 | + X(uint64_t, moqFetchError) \ |
| 71 | + X(uint64_t, moqPublishNamespaceSuccess) \ |
| 72 | + X(uint64_t, moqPublishNamespaceError) \ |
| 73 | + X(uint64_t, quicPacketsReceived) \ |
| 74 | + X(uint64_t, quicPacketsSent) \ |
| 75 | + X(uint64_t, quicPacketsDropped) \ |
| 76 | + X(uint64_t, quicPacketLoss) \ |
| 77 | + X(uint64_t, quicConnectionsCreated) \ |
| 78 | + X(uint64_t, quicConnectionsClosed) |
| 79 | + |
| 80 | +#define STATS_GAUGE_FIELDS(X) \ |
| 81 | + X(int64_t, moqActiveSubscriptions) \ |
| 82 | + X(int64_t, moqActiveSessions) |
| 83 | + |
| 84 | +// Histograms: (name, constexpr_boundaries_ref) |
| 85 | +// Each expands to: name##Buckets[] + name##Sum + name##Count |
| 86 | +inline constexpr std::array<uint64_t, 8> kLatencyBucketsMs = {1, 5, 10, 50, 100, 500, 1000, 5000}; |
| 87 | +#define STATS_HISTOGRAM_FIELDS(X) \ |
| 88 | + X(moqSubscribeLatency, kLatencyBucketsMs) \ |
| 89 | + X(moqFetchLatency, kLatencyBucketsMs) |
| 90 | + |
| 91 | +struct StatsSnapshot { |
| 92 | + // Scalar fields |
| 93 | +#define DEFINE_FIELD(type, name) type name{0}; |
| 94 | + STATS_COUNTER_FIELDS(DEFINE_FIELD) |
| 95 | + STATS_GAUGE_FIELDS(DEFINE_FIELD) |
| 96 | +#undef DEFINE_FIELD |
| 97 | + |
| 98 | + // Histogram fields: bucket array (+Inf bucket), sum, count |
| 99 | +#define DEFINE_HISTOGRAM(name, bounds) \ |
| 100 | + std::array<uint64_t, std::tuple_size_v<decltype(bounds)> + 1> name##Buckets{}; \ |
| 101 | + uint64_t name##Sum{0}; \ |
| 102 | + uint64_t name##Count{0}; |
| 103 | + STATS_HISTOGRAM_FIELDS(DEFINE_HISTOGRAM) |
| 104 | +#undef DEFINE_HISTOGRAM |
| 105 | + |
| 106 | + StatsSnapshot& operator+=(const StatsSnapshot& o) { |
| 107 | +#define ADD_FIELD(type, name) name += o.name; |
| 108 | + STATS_COUNTER_FIELDS(ADD_FIELD) |
| 109 | + STATS_GAUGE_FIELDS(ADD_FIELD) |
| 110 | +#undef ADD_FIELD |
| 111 | +#define ADD_HISTOGRAM(name, bounds) \ |
| 112 | + for (size_t i = 0; i < name##Buckets.size(); ++i) name##Buckets[i] += o.name##Buckets[i]; \ |
| 113 | + name##Sum += o.name##Sum; name##Count += o.name##Count; |
| 114 | + STATS_HISTOGRAM_FIELDS(ADD_HISTOGRAM) |
| 115 | +#undef ADD_HISTOGRAM |
| 116 | + return *this; |
| 117 | + } |
| 118 | + |
| 119 | + static std::string formatPrometheus(const StatsSnapshot&); |
| 120 | + // formatPrometheus uses STATS_COUNTER_FIELDS, STATS_GAUGE_FIELDS, STATS_HISTOGRAM_FIELDS |
| 121 | + // to emit # HELP / # TYPE / value lines; histogram emits _bucket{le=...}/_sum/_count |
| 122 | +}; |
| 123 | +``` |
| 124 | +
|
| 125 | +### StatsCollectorBase |
| 126 | +```cpp |
| 127 | +class StatsCollectorBase { |
| 128 | + public: |
| 129 | + virtual ~StatsCollectorBase() = default; |
| 130 | + // Must be called on owningExecutor() |
| 131 | + virtual StatsSnapshot snapshot() const = 0; |
| 132 | + virtual folly::Executor::KeepAlive<> owningExecutor() const = 0; |
| 133 | +}; |
| 134 | +``` |
| 135 | + |
| 136 | +**Note on `moqActiveSessions`:** `+1` in `onNewSession()`, `-1` in `terminateClientSession()` covers server-side sessions. If o-rly later acts as a client (upstream relay connection), those sessions have a different lifecycle — a separate counter or hook will be needed at that point. |
| 137 | + |
| 138 | +--- |
| 139 | + |
| 140 | +## Phase 0: AdminServer Framework |
| 141 | + |
| 142 | +**Thesis:** Establish a minimal, standalone proxygen HTTP/1.1+H2 admin server with a generic request aggregator. No stats yet — just the framework that later phases build on. |
| 143 | + |
| 144 | +### Core design: generic request aggregator |
| 145 | + |
| 146 | +Route handlers are simple callbacks rather than full `proxygen::RequestHandler` subclasses. A single `AdminRequestHandler` accumulates the complete request (headers + body) and then dispatches: |
| 147 | + |
| 148 | +```cpp |
| 149 | +// Route handler signature: receives complete request, owns the response |
| 150 | +using RouteHandler = std::function<void( |
| 151 | + std::unique_ptr<proxygen::HTTPMessage> req, // headers, path, query params, method |
| 152 | + std::unique_ptr<folly::IOBuf> body, // complete body (may be empty) |
| 153 | + proxygen::ResponseHandler* downstream // send headers/body/EOM here |
| 154 | +)>; |
| 155 | + |
| 156 | +// Generic aggregator — the only proxygen::RequestHandler subclass needed |
| 157 | +class AdminRequestHandler : public proxygen::RequestHandler { |
| 158 | + RouteHandler handler_; |
| 159 | + std::unique_ptr<proxygen::HTTPMessage> req_; |
| 160 | + folly::IOBufQueue body_{folly::IOBufQueue::cacheChainLength()}; |
| 161 | + public: |
| 162 | + explicit AdminRequestHandler(RouteHandler handler); |
| 163 | + void onRequest(std::unique_ptr<proxygen::HTTPMessage>) noexcept override; |
| 164 | + void onBody(std::unique_ptr<folly::IOBuf>) noexcept override; // appends to body_ |
| 165 | + void onEOM() noexcept override; // calls handler_(req_, body_.move(), downstream_) |
| 166 | + void onUpgrade(proxygen::UpgradeProtocol) noexcept override {} |
| 167 | + void requestComplete() noexcept override { delete this; } |
| 168 | + void onError(proxygen::ProxygenError) noexcept override { delete this; } |
| 169 | +}; |
| 170 | +``` |
| 171 | +
|
| 172 | +`AdminHandlerFactory : proxygen::RequestHandlerFactory` holds a route table (method + path → `RouteHandler`); `onRequest()` looks up the route and returns an `AdminRequestHandler`. Unknown routes get a 404 handler. |
| 173 | +
|
| 174 | +Async handlers (e.g., MetricsHandler that needs `co_await`) launch a coroutine from within the `RouteHandler` and send the response asynchronously via `downstream`: |
| 175 | +
|
| 176 | +```cpp |
| 177 | +// Registering the metrics route (sketch) |
| 178 | +adminServer.addRoute("GET", "/metrics", [this](auto req, auto body, auto* downstream) { |
| 179 | + folly::coro::co_invoke([this, downstream]() -> folly::coro::Task<void> { |
| 180 | + auto snapshot = co_await registry_.aggregateAsync(); |
| 181 | + auto text = StatsSnapshot::formatPrometheus(snapshot); |
| 182 | + ResponseBuilder(downstream).status(200, "OK") |
| 183 | + .header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") |
| 184 | + .body(folly::IOBuf::copyBuffer(text)).sendWithEOM(); |
| 185 | + }).scheduleOn(evb_).start(); |
| 186 | +}); |
| 187 | +``` |
| 188 | + |
| 189 | +### Files to Create |
| 190 | +- `include/o_rly/admin/AdminServer.h` / `src/admin/AdminServer.cpp` |
| 191 | + - `AdminRequestHandler` (aggregator, as above) |
| 192 | + - `AdminHandlerFactory : proxygen::RequestHandlerFactory` |
| 193 | + - `AdminServer`: wraps `proxygen::HTTPServer` with `IOThreadPoolExecutor(1)`; `addRoute(method, path, RouteHandler)`; `start(port)` / `stop()` |
| 194 | + - Comments note: TLS/mTLS via `wangle::SSLContextConfig` (future, PR #29 config); future routes like `GET /sessions` for inspection |
| 195 | + - **Code comment:** Uses `proxygen/httpserver/HTTPServer.h`; future migration to `proxygen/lib/http/coro/server/HTTPServer.h` |
| 196 | + |
| 197 | +### Files to Modify |
| 198 | +- `src/main.cpp` |
| 199 | + - Add `DEFINE_int32(admin_port, 9669, "HTTP admin port (0 to disable)")` |
| 200 | + - Create `AdminServer`, register routes, call `start()` before `evb.loopForever()`; `admin_port=0` skips it |
| 201 | + - First route registered: `GET /info` → lambda returning JSON version string (validates the framework) |
| 202 | + - Addresses the existing `TODO: health checks / admin endpoints` comment |
| 203 | +- `CMakeLists.txt` — add `proxygen::proxygenhttpserver`; `src/admin/AdminServer.cpp` to `o_rly_core` |
| 204 | + |
| 205 | +### Files to Modify |
| 206 | +- `src/main.cpp` |
| 207 | + - Add `DEFINE_int32(admin_port, 9669, "HTTP admin port (0 to disable)")` |
| 208 | + - Create `AdminServer`, start before `evb.loopForever()`; `admin_port=0` skips it |
| 209 | + - Addresses the existing `TODO: health checks / admin endpoints` comment |
| 210 | +- `CMakeLists.txt` — add `proxygen::proxygenhttpserver`; new `src/admin/*.cpp` to `o_rly_core` |
| 211 | + |
| 212 | +--- |
| 213 | + |
| 214 | +## Phase 1: Stats Registry + MoQ Stats + Latency Histogram (POC) |
| 215 | + |
| 216 | +**Thesis:** Implement `StatsRegistry` with folly::coro aggregation, `MoQStatsCollector` for MoQ-level metrics, and a `folly::Histogram<int64_t>`-backed latency histogram; expose via `GET /metrics`. |
| 217 | + |
| 218 | +### Files to Create |
| 219 | +- `include/o_rly/stats/StatsRegistry.h` / `src/stats/StatsRegistry.cpp` |
| 220 | + - `StatsCollectorBase` interface (see above) |
| 221 | + - `StatsSnapshot` struct with X-macro fields + histogram bucket arrays (see above) |
| 222 | + - `StatsRegistry`: mutex-protected collector list; `aggregateAsync() -> folly::coro::Task<StatsSnapshot>`; snapshot cache with TTL + `?nocache=1` bypass |
| 223 | +- `include/o_rly/stats/MoQStatsCollector.h` / `src/stats/MoQStatsCollector.cpp` |
| 224 | + - Implements `MoQPublisherStatsCallback` + `MoQSubscriberStatsCallback` + `StatsCollectorBase` |
| 225 | + - Plain counters (`uint64_t` / `int64_t`); `folly::Histogram<int64_t>` for subscribe and fetch latency |
| 226 | + - Registers with `StatsRegistry` on construction, deregisters on destruction |
| 227 | + - `snapshot()`: copies plain counters + copies histogram bucket cumulative counts into `StatsSnapshot` |
| 228 | +- `src/admin/MetricsHandler.cpp` (no separate header needed) |
| 229 | + - A `RouteHandler` function (or lambda) registered with `AdminServer` for `GET /metrics` |
| 230 | + - Uses `co_await registry_.aggregateAsync()` inside a coroutine launched from the handler; sends Prometheus text response via `downstream` |
| 231 | + - No `proxygen::RequestHandler` subclass — the aggregator framework handles that |
| 232 | + |
| 233 | +### Files to Modify |
| 234 | +- `include/o_rly/ORelayServer.h` — add `std::shared_ptr<StatsRegistry> statsRegistry_` |
| 235 | +- `src/ORelayServer.cpp` |
| 236 | + - `onNewSession()`: create `MoQStatsCollector` per session, call `session->setPublisherStatsCallback()` + `setSubscriberStatsCallback()` |
| 237 | + - `terminateClientSession()`: decrement `moqActiveSessions` before session goes away |
| 238 | +- `CMakeLists.txt` — add new `src/stats/*.cpp`, `src/admin/MetricsHandler.cpp` |
| 239 | + |
| 240 | +### Latency Histogram POC |
| 241 | +`MoQStatsCollector` holds one `folly::Histogram<int64_t>` per histogram defined in `STATS_HISTOGRAM_FIELDS`. In `recordSubscribeLatency(uint64_t latencyMs)`: `subscribeLatencyHistogram_.addValue(latencyMs)` (on owning executor, no sync needed). In `snapshot()`: copy cumulative bucket counts from each `folly::Histogram` into the corresponding `name##Buckets` array in `StatsSnapshot` — also fill `name##Sum` and `name##Count`. `formatPrometheus()` uses `STATS_HISTOGRAM_FIELDS` macro to emit `_bucket{le=...}`, `_sum`, `_count` lines for each histogram. Adding histogram #3 is one line in `STATS_HISTOGRAM_FIELDS` and one `folly::Histogram` member in the collector. |
| 242 | + |
| 243 | +--- |
| 244 | + |
| 245 | +## Phase 2: QUIC Transport Stats |
| 246 | + |
| 247 | +**Thesis:** Implement `QuicStatsCollector` per QUIC worker; plug into `StatsRegistry`. |
| 248 | + |
| 249 | +### Files to Create |
| 250 | +- `include/o_rly/stats/QuicStatsCollector.h` / `src/stats/QuicStatsCollector.cpp` |
| 251 | + - `QuicStatsCollector : quic::QuicTransportStatsCallback + StatsCollectorBase` |
| 252 | + - Plain `uint64_t` counters; `owningExecutor()` captures worker's executor at construction (via `folly::getEventBase()` / keep-alive at factory `make()` time) |
| 253 | + - Overrides only the 6 methods in Stats Tracked; remaining ~44 are default no-ops |
| 254 | + - Registers/deregisters with `StatsRegistry` |
| 255 | + - `QuicStatsCollectorFactory : quic::QuicTransportStatsCallbackFactory` |
| 256 | + - `make()` creates a `QuicStatsCollector` pointing at the shared `StatsRegistry` |
| 257 | + |
| 258 | +### Files to Modify |
| 259 | +- `src/ORelayServer.cpp` — call `setQuicStatsFactory(std::make_unique<QuicStatsCollectorFactory>(statsRegistry_))` in constructor |
| 260 | +- `CMakeLists.txt` — verify `mvfst::mvfst_transport` linked (likely transitive via moxygen) |
| 261 | + |
| 262 | +### Stats Tracked |
| 263 | +- `quicPacketsReceived`, `quicPacketsSent`, `quicPacketsDropped`, `quicPacketLoss`, `quicConnectionsCreated`, `quicConnectionsClosed` |
| 264 | + |
| 265 | +**TODO:** Count QUIC-direct vs WebTransport sessions separately — needs a moxygen hook to distinguish `createMoQQuicSession()` vs WebTransport `Handler` path. |
| 266 | + |
| 267 | +--- |
| 268 | + |
| 269 | +## CMakeLists.txt Changes Summary |
| 270 | + |
| 271 | +| Phase | New sources | New link targets | |
| 272 | +|-------|------------|-----------------| |
| 273 | +| 0 | `src/admin/AdminServer.cpp`, `InfoHandler.cpp` | `proxygen::proxygenhttpserver` | |
| 274 | +| 1 | `src/stats/StatsRegistry.cpp`, `MoQStatsCollector.cpp`, `src/admin/MetricsHandler.cpp` | likely transitive | |
| 275 | +| 2 | `src/stats/QuicStatsCollector.cpp` | verify `mvfst::mvfst_transport` | |
| 276 | + |
| 277 | +--- |
| 278 | + |
| 279 | +## Verification |
| 280 | + |
| 281 | +- **Phase 0:** `curl http://localhost:9669/info` → JSON; `curl .../other` → 404; `--admin_port=0` → clean start |
| 282 | +- **Phase 1:** Connect MoQ client; `curl .../metrics` → Prometheus text with counters and histogram buckets; reconnect → counters increment; `?nocache=1` bypasses cache |
| 283 | +- **Phase 2:** Establish QUIC connection; `curl .../metrics` → `quic_connections_created_total` increments |
0 commit comments