Skip to content

Telemetry page: both directions, every backend, and the counters behind them - #91

Merged
emir-hasanbegovic merged 1 commit into
mainfrom
feat/telemetry-page
Sep 4, 2026
Merged

Telemetry page: both directions, every backend, and the counters behind them#91
emir-hasanbegovic merged 1 commit into
mainfrom
feat/telemetry-page

Conversation

@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

Description

The Debug Telemetry page was showing one direction, one backend, and one
misleading colour. GET /api/debug was eight inbound counters plus the
preferred backend's name, so on a Windows box with both drivers the page
only ever said "ViGEm"; it painted an idle-but-healthy host red because it
read backendAvailable (which means "the bus is open right now", not "the
driver works"); and it kept polling /api/debug twice a second for the life
of the tab once you had visited it.

GET /api/debug now carries rx, tx, audio and auth blocks plus the
host gauges the page had been guessing at, and the page is rebuilt around
them.

Server

Block What it answers
rx Accepted inbound messages by type, and the four ways a datagram is refused before any decoder runs (malformed = known opcode that failed its length guard, unknownType, runt, unknownToken)
tx The direction that had no telemetry at all: datagrams and bytes, split across every message the server sends, plus unroutable / encryptFailed / oversize / sendFailed
audio Mic frames accepted / too-late / dropped (a partition of every inbound frame), what reached the pad by decode, by Opus in-band FEC and by concealment, and outbound frames sent / suppressed as digital silence / encode-failed / lost to lock contention
auth Client-API 401s split notPaired / badProof

Plus peakLoopUs, webPort, mdnsResponderActive, clientApiListening,
live connection and controller counts, and sessionsReaped.

Two bugs fixed on the way:

  • sendto's return value was discarded in ClientAdapter, so an
    ENOBUFS or EHOSTUNREACH was invisible. It is checked now and feeds
    tx.sendFailed.
  • maxLoopUs reads 0 nearly always. /api/debug zeroes it on read,
    but the receiver's thread-local high-water mark never resets, so the
    atomic only climbs again on a new all-time record. The window semantics
    are kept (the local bench harness reads them), and a new peakLoopUs
    folds each windowed read into the true peak.
  • Also: webPort was read by the page but never sent by the server.

Hot path

Untouched, and the split is deliberate rather than incidental. Nothing was
added to the accepted-packet path:

  • inbound counters sit only in the receiver's rejection branches and its
    cold non-gamepad dispatch branch;
  • DispatchResult gained a handled flag so the receiver can tell a
    malformed frame from an unrecognised one without re-parsing, which is
    what keeps inner_dispatch.cpp free of globals (its test target does not
    link globals.cpp);
  • no outbound message is reachable from the gamepad path, so counting in
    sendEncryptedPacket is free;
  • audio counters run at 50 Hz per controller under locks those paths
    already hold.

Inbound byte counting is deliberately absent: it would cost an atomic add
per packet on the accepted path.

src/app/wire_stats.h is header-only with an inline global, so there is
no per-platform globals.cpp for a new counter to drift out of.

Page

Bidirectional flow diagram, six grouped sections (54 rows), a mirrored
in/out traffic chart, and a list of every backend the host reports with
its vendor, kernel/user mode, audio capability, lifecycle and
installed-versus-bundled driver version. Idle reads idle instead of error,
polling stops on navigation, and a pre-2.0.0 satellite degrades to em
dashes rather than rendering undefined. 80 debug strings across all six
locales.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change
  • Refactor
  • Documentation update

How Has This Been Tested?

  • Unit tests (ctest / test_session_service / test_windows_platform)
  • Manual testing on Windows
  • Manual testing on macOS
  • Manual testing on Linux
  • Tested against a paired client end-to-end

Automated. Full ctest 35/35 on the MinGW lane, scripts/check-format.sh
clean against clang-format 22.1.4, scripts/check_core_purity.sh clean.

  • New test_wire_stats (108 assertions): every opcode's mapping in both
    directions, the malformed-vs-unknown split, the deleted registration
    opcodes staying "unknown", the peak fold across a zeroing read, and
    reset() coverage checked generically through forEachCounter, so
    forgetting to reset a future counter fails the test rather than silently
    leaking across a rebind.
  • test_receiver: handled asserted for every accepted message type and
    every length-guard rejection, including the subtle case that a
    well-formed frame the service then refuses on policy (no CAP_MIC) still
    counts as handled, because that is what separates rx.malformed from a
    policy drop.
  • test_session_service: audio counters cross-checked against the existing
    concealment/reorder scenario (accepted, decoded, FEC-recovered and late
    all agree with submitMicAudioPcm call counts) and the
    silence-suppression scenario.
  • test_status_json: golden shape updated, plus a new test pinning the
    counter blocks to /api/debug and keeping them off /api/status and the
    1 Hz SSE tick.

Page. Rendered headless (Chrome, DOM asserted, not just eyeballed)
against five payload shapes and three locales:

Scenario What it proves
Busy Windows host, both drivers Both directions populate; both backends named with versions
Idle host The backend stage reads Idle, not error — the reported bug
ViGEmBus missing + helper missing The flow does go red; every rejection counter surfaces
Live 1.1.0-dev server (the installed build) New blocks degrade to em dashes, no NaN/undefined, both backends still listed
Linux/uinput payload No Windows-only assumptions
?lang=de, ?lang=fr Translations render, no raw keys, no overflow

Also verified: the rate arithmetic (including the counter-reset and
zero-elapsed guards) and that navigating away clears both timers, with the
showView wrappers in debug.js and logs.js chaining rather than
clobbering.

Not tested: an end-to-end run with a paired client streaming controller
audio, so the audio block's live values have not been observed against
real traffic (the counters themselves are unit-tested against real Opus).
macOS and Linux builds were not run locally.

Checklist

  • My code follows the project's style guidelines (clang-format clean)
  • I have performed a self-review of my code
  • I have commented my code in hard-to-understand areas
  • I have made corresponding changes to documentation
  • My changes generate no new compiler warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

…nd them

## Summary

The diagnostics page showed inbound UDP only, named just the preferred
backend (so a Windows box with both drivers only ever said "ViGEm"), and
painted an idle-but-healthy host red. It also kept polling `/api/debug`
twice a second forever once visited.

`GET /api/debug` gains `rx`, `tx`, `audio` and `auth` blocks plus the host
gauges the page had been inferring, and the page is rebuilt around them.

## Server

- `src/app/wire_stats.h`: one header-only `WireStats` for per-message wire
  counters, so the boundary is visible in one place. Header-only inline, so
  no per-platform `globals.cpp` can drift.
- Inbound: per-type counts in the receiver's cold dispatch branch, and the
  rejections that never reach a decoder (runt, unknown token, malformed,
  unknown type). `DispatchResult` gained a `handled` flag so the receiver
  can split "known opcode, failed its length guard" from "unrecognised"
  without re-parsing, which keeps `inner_dispatch.cpp` globals-free as its
  portable test build requires.
- Outbound: counted centrally in `ClientAdapter::sendEncryptedPacket`,
  which now also checks `sendto`'s return value (it was discarded).
- Audio: `SessionService` counts mic frames accepted/late/dropped and
  decoded/FEC-recovered/concealed, and speaker frames sent, suppressed as
  digital silence, encode-failed and lost to lock contention. Core-pure
  atomics, no new locks.
- `peakLoopUs`: `maxLoopUs` keeps its read-and-zero window semantics for
  the bench harness, but the receiver's thread-local high-water mark means
  a zeroed value only rises again on a new all-time record, so the page's
  "peak" read 0 nearly always. `/api/debug` now folds each windowed read
  into a true peak.
- Also fixed: `webPort` was read by the page but never sent.

## Hot path

Untouched. Nothing was added to the accepted-packet path: the inbound
counters sit only in rejection branches and the cold non-gamepad branch,
no outbound message is reachable from the gamepad path, and the audio
counters run at 50 Hz per controller under locks those paths already hold.
Inbound byte counting is deliberately absent because it would cost an
atomic add per packet.

## Page

Bidirectional flow diagram, six grouped sections (54 rows), a mirrored
in/out traffic chart, and a list of every backend the host reports with
vendor, mode, audio capability, lifecycle and installed-vs-bundled driver
version. Idle now reads idle rather than error, polling stops on
navigation, and an older satellite degrades to em dashes instead of
`undefined`. 80 debug strings across all six locales.

## Tests

- New `test_wire_stats` (108 assertions): every opcode's mapping in both
  directions, the malformed/unknown split, reset coverage checked
  generically so a forgotten counter fails, and the peak fold.
- `test_receiver`: `handled` asserted for every accepted type and every
  length-guard rejection, including that a well-formed frame the service
  then refuses on policy still counts as handled.
- `test_session_service`: audio counters cross-checked against the
  existing concealment/reorder scenario and the silence-suppression one.
- `test_status_json`: golden shape updated; new test pins the counter
  blocks to `/api/debug` and keeps them off the hot status/SSE surfaces.
- Full ctest 35/35, clang-format clean, core purity gate clean.
- Page verified by rendering it headless against five payload shapes
  (busy, idle, driver-missing, pre-2.0.0 server, Linux/uinput) and two
  extra locales, and against the live 1.1.0-dev server for backwards
  compatibility.

## Docs

`docs/contract.md` documents the new `/api/debug` fields under the admin
API; `docs/architecture.md` records the counter placement rule and the
peak-vs-window distinction; CHANGELOG 2.0.0 gains a paragraph.
@emir-hasanbegovic
emir-hasanbegovic merged commit b8ef920 into main Sep 4, 2026
14 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the feat/telemetry-page branch September 4, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant