Skip to content

[REA-6142] Export the transport statistics of a live WebRTC wire - #179

Merged
douglaseel merged 5 commits into
mainfrom
douglas/rea-6142-export-webrtc-session-metrics
Sep 11, 2026
Merged

douglaseel merged 5 commits into
mainfrom
douglas/rea-6142-export-webrtc-session-metrics

Conversation

@douglaseel

Copy link
Copy Markdown
Contributor

The peer has sampled the wire every two seconds all along and nothing read
the result: a sample reached a log line when it carried a discard and was
dropped otherwise. A viewer reporting a stuttering picture had no number
behind it, because the metrics that used to carry one went away with the
2.8.x gauges.

Reading those samples properly meant taking reactor-webrtc 0.16.0, whose
report carries what 0.14.0 could not. The receiver's RTCP reports say what it
never got and how long the stream took to reach it, so outbound loss and the
round trip media actually travelled are measurements now rather than
guesses — and that round trip is not the one ICE takes, because connectivity
checks keep succeeding on a path whose media queue has grown. Congestion
control says what it believes the path will hold, which is the figure that
separates a model that stopped producing frames from a network that stopped
accepting them. The encoder says how many frames reached the wire, so the
frame rate a viewer saw is measured rather than inferred from the rate the
model emitted. ICE also marks the nominated pair, so the path's own round
trip and its estimate come from the pair carrying media instead of from the
first one that happened to succeed.

Bitrate is deliberately not an instrument of its own. Bytes sent and bytes
received are counted, and a rate over either is the bitrate the track
achieved — which is the number worth having, where the encoder's target
bitrate only ever said what it was aiming at.

Every count arrives as a total for the life of the wire, so a counter cannot
take it straight. One recorder per connection holds the previous sample and
moves each counter by what the window cost, then dies with its connection
while the instruments stay on the shared registry. That keeps the series a
process holds fixed however many connections it serves, and it is the
opposite of the 2.8.x shape, where a track's last value was republished on
every scrape until the process died and a flat plateau read as a measurement.
Loss shares one counter across both directions because the direction is what
tells the two apart, and the loss fraction the receiver reports rides a
histogram beside it, so a query reads the share of a stream that went missing
without dividing two counters. A zero fraction counts: dropping it would
leave the histogram holding only the bad windows. What the receiver has not
reported on yet is absent instead, since libwebrtc holds that round trip at
zero until the first report lands and a zero there is silence, not a clean
path.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Signed-off-by: Douglas Ferreira douglaseel@gmail.com

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@douglaseel
douglaseel force-pushed the douglas/rea-6142-export-webrtc-session-metrics branch from f316d8c to 8cad1be Compare September 10, 2026 15:24

@ggoldens ggoldens left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, please check the unit tests since they are failing.

The peer has sampled the wire every two seconds all along and nothing read
the result: a sample reached a log line when it carried a discard and was
dropped otherwise. A viewer reporting a stuttering picture had no number
behind it, because the metrics that used to carry one went away with the
2.8.x gauges.

Reading those samples properly meant taking reactor-webrtc 0.17.0, whose
report carries what 0.14.0 could not. The receiver's RTCP reports say what it
never got and how long the stream took to reach it, so outbound loss and the
round trip media actually travelled are measurements now rather than
guesses — and that round trip is not the one ICE takes, because connectivity
checks keep succeeding on a path whose media queue has grown. Congestion
control says what it believes the path will hold, which is the figure that
separates a model that stopped producing frames from a network that stopped
accepting them. The encoder says how many frames reached the wire, so the
frame rate a viewer saw is measured rather than inferred from the rate the
model emitted. ICE also marks the nominated pair, so the path's own round
trip and its estimate come from the pair carrying media instead of from the
first one that happened to succeed.

The repair traffic is the earliest reading of the four, because a
retransmission that arrives in time hides the loss that prompted it: a path
going bad shows up in what the far end is asking for while the picture is
still intact and loss is still zero. Requests and repairs are counted apart
on purpose — they track each other while a path is merely lossy and diverge
once requests start going unanswered, which is what says repair is no longer
keeping up. Keyframe requests are the level past that, sent when a decoder
cannot continue at all, and Picture Loss Indications and Full Intra Refresh
requests share one counter because they are the same request in two codec
dialects.

Bitrate is deliberately not an instrument of its own. Bytes sent and bytes
received are counted, and a rate over either is the bitrate the track
achieved — which is the number worth having, where the encoder's target
bitrate only ever said what it was aiming at.

Every count arrives as a total for the life of the wire, so a counter cannot
take it straight. One recorder per connection holds the previous sample and
moves each counter by what the window cost, then dies with its connection
while the instruments stay on the shared registry. That keeps the series a
process holds fixed however many connections it serves, and it is the
opposite of the 2.8.x shape, where a track's last value was republished on
every scrape until the process died and a flat plateau read as a measurement.
Loss, the repair requests and the keyframe requests each share one counter
across both directions, because the direction is what tells the two apart.
The loss fraction the receiver reports rides a histogram beside the counters,
so a query reads the share of a stream that went missing without dividing
two of them. A zero fraction counts: dropping it would leave the histogram
holding only the bad windows. What the receiver has not reported on yet is
absent instead, since libwebrtc holds that round trip at zero until the first
report lands and a zero there is silence, not a clean path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Douglas Ferreira <douglaseel@gmail.com>
@douglaseel
douglaseel force-pushed the douglas/rea-6142-export-webrtc-session-metrics branch from 8cad1be to 4de314b Compare September 10, 2026 16:42
@douglaseel
douglaseel marked this pull request as ready for review September 10, 2026 16:42
@douglaseel
douglaseel requested a review from a team as a code owner September 10, 2026 16:42
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

[codex-review] - [P1] acceptor.py: Client-controlled track labels allow unbounded Prometheus series growth.

  • [P2] metrics.py: Differencing keys conflate same-named inbound and outbound counters.

Scope: full (f01fd6f..4de314b).

View workflow run.

Comment thread src/reactor_runtime/transport/webrtc/acceptor.py
Comment thread src/reactor_runtime/metrics.py Outdated
douglaseel and others added 3 commits September 10, 2026 13:56
Every metrics test so far builds a holder by hand and reads the registry back
directly, which says the instrument was declared and observed correctly but
nothing about whether the group that declared it is the one `/metrics`
renders. The endpoint had its own tests, and they asserted only the identity
series, so a component wired to a holder of its own would pass the whole
suite while its metrics stayed live in memory and absent from every scrape.

That is the failure this change set out to fix in the first place, so it is
worth a test rather than an assumption. This one goes through the assembly and
the endpoint and names the families a scrape has to carry. Breaking the wiring
on purpose — handing the transport router a holder of its own — fails it, which
is how I know it is not asserting something that cannot go wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Douglas Ferreira <douglaseel@gmail.com>
Use the model manifest to bound track labels and aggregate undeclared names under unknown. Keep cumulative baselines separate by track and metric labels so inbound and outbound totals cannot interfere.

Signed-off-by: Douglas Ferreira <douglaseel@gmail.com>
Keep transport instruments beside their statistics types and isolate the shared registry so imports remain acyclic. Re-export the metric classes from the main metrics module to preserve existing imports.

Signed-off-by: Douglas Ferreira <douglaseel@gmail.com>

@Dere-Wah Dere-Wah left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The measurement design here is the strong part: differencing per connection while the instruments stay on the shared registry is the right answer to counters that arrive as lifetime totals, and separating the ICE round trip from the one the receiver measures on the stream is the distinction that makes the data actionable. Coverage is real too — test_serve asserting the families reach /metrics through the assembly catches the failure mode a registry-by-hand test cannot.

One structural comment on where the new module sits, and two smaller ones inline. None of them block; approving on the assumption they get a look before merge.

On the two earlier codex-review findings, both read as resolved at this head: allowed_tracks/label() bounds the label values (P1), and _advance's key now carries the direction labels (P2). No action needed.

Comment thread src/reactor_runtime/metrics.py Outdated
Comment on lines +46 to +52
from reactor_runtime.runtime_metrics import RuntimeMetrics as RuntimeMetrics
from reactor_runtime.transport.webrtc.metrics import (
ConnectionStatsRecorder as ConnectionStatsRecorder,
)
from reactor_runtime.transport.webrtc.metrics import (
WebRtcMetrics as WebRtcMetrics,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The dependency arrow between these two modules now points both ways: metrics imports transport.webrtc.metrics for the re-exports, that module imports the registry back, and runtime_metrics.py exists to break the cycle that creates. So a new root module is paying for a re-export rather than for a layering decision — and metrics.py / runtime_metrics.py sitting side by side under near-identical names is hard to navigate.

Worth noting nothing in src/ reads either symbol off this module. router.py and acceptor.py both import WebRtcMetrics from transport.webrtc.metrics directly. The only two consumers are tests/unit/transport/webrtc/test_acceptor.py:9 and test_webrtc_metrics.py:6.

Suggest pointing those two test imports at the real module, dropping lines 47-52, and folding RuntimeMetrics back in here. The arrow then runs one way — transport.webrtc.metricsmetrics — which is the direction main already has, and the cycle-breaker module goes away. I tried it locally: net −5 lines, one module fewer, ruff check/ruff format clean, 1442 unit tests passing.

If the registry is meant to be its own layer rather than an accident of the cycle, that is a fine call to make — but then it is worth making it explicitly and giving it a name and a docstring that say so, since a metrics package with the registry in __init__ and one module per subsystem is the shape that scales to a second transport without another root module.

the peer left unset is skipped rather than counted as no movement.
"""
name = track.name
group = self._metrics

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ConnectionStatsRecorder reads twenty private attributes off WebRtcMetrics (group._packets_sent, self._metrics._rtt, and so on). It works because the two share a module, but it makes them one unit with a seam drawn through the middle, and SLF is not in the ruff select list, so nothing will flag it if the pattern spreads.

Consider holding the instruments in a small frozen dataclass that WebRtcMetrics builds once and passes to the recorder. The twenty _ reads become ordinary attribute access on a value object the recorder legitimately owns, and the split between "declares instruments" and "differences totals" becomes one a reader can see rather than one the module boundary hides.

config: WebRtcConfig,
peer_factory: WebRtcPeerFactory,
metrics: WebRtcMetrics,
track_names: Callable[[], Iterable[str]] | None = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

track_names is annotated Callable[[], Iterable[str]] and receives runner.track_map, which returns Mapping[str, Any]. It type-checks and yields the right values, but only because iterating a mapping gives its keys — the parameter asks for names and the caller supplies a manifest. Passing lambda: runner.track_map().keys() at the router.py call site would make the intent legible without changing behaviour.

@Dere-Wah Dere-Wah left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Following up on the layering comment with the point that matters more than the cycle itself: where this leaves the package root. Two inline notes proposing a metrics/ package.

Comment on lines +1 to +6
"""Own the shared Prometheus registry and process identity."""

from prometheus_client import CollectorRegistry, Info, generate_latest


class RuntimeMetrics:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth separating this from the cycle argument, because it outlives it: this file sets a precedent for a surface that grows a root module per subsystem.

Metrics now live in three places — metrics.py at 460 lines, this at 36, and transport/webrtc/metrics.py at 426 — and nothing in the two root filenames tells a reader which to open. The next subsystem that needs the registry and cannot import metrics.py gets a fourth. The package root already carries eleven single modules against seven packages, so it is the part of the tree least able to absorb that.

Every other subsystem here that outgrew one file became a package instead: core, http, interface, protocol, recording, runner, transport, each with an __init__ that re-exports its surface behind an __all__. Metrics is the one that grew and split sideways.

The shape that matches the rest of the tree:

src/reactor_runtime/metrics/
    __init__.py    # CONTENT_TYPE, RuntimeMetrics, MetricsRecorder, CommandMetrics, ModelMetrics
    registry.py    # RuntimeMetrics — this file, correctly placed
    session.py     # MetricsRecorder, _reason_label, the session buckets
    command.py     # CommandMetrics, UNKNOWN_COMMAND
    model.py       # ModelMetrics

Three things make it cheap. The import path reactor_runtime.metrics is unchanged, so all fourteen existing call sites across serve, runner, http/*, and the tests stay as they are — it is a pure move. The cycle cannot come back, because the three recorder modules import metrics.registry while __init__ imports the four, and nothing in the package imports transport. And the bucket constants stop being a single undifferentiated block at the top of a 460-line file: each one sits with the instrument it shapes, where the comment explaining its boundaries is actually readable.

It also answers the scaling question directly. A second transport, or a metering or recording instrument group, adds a module beside its own code and imports the registry — the root stops growing a module per subsystem, which is the failure this file starts.

from prometheus_client import Counter, Histogram

from reactor_runtime.core import TrackDirection
from reactor_runtime.runtime_metrics import RuntimeMetrics

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To be clear about scope: under the metrics/ package layout this file does not move. Instruments belong beside the code that observes them, and main already had transport/webrtc/{router,acceptor}.py importing the metrics module, so putting the WebRTC group here is the right call and the arrow direction is unchanged.

The only edit here is this line becoming from reactor_runtime.metrics import RuntimeMetrics — which is what router.py and acceptor.py already wrote before this PR. The problem to fix is at the root, not in this directory.

Group the registry and runtime metrics in a package. Pass shared WebRTC
instruments to connection recorders through a frozen dataclass, and
supply track-name keys explicitly at the router.

Signed-off-by: Douglas Ferreira <douglaseel@gmail.com>

douglaseel commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Sep 11, 7:10 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Sep 11, 7:10 PM UTC: @douglaseel merged this pull request with Graphite.

@douglaseel
douglaseel merged commit ad45b88 into main Sep 11, 2026
9 checks passed
@douglaseel
douglaseel deleted the douglas/rea-6142-export-webrtc-session-metrics branch September 11, 2026 19:10
douglaseel added a commit that referenced this pull request Sep 11, 2026
## Why

Publish the live WebRTC transport metrics from PR #179 in a stable runtime release. The HTTP contract is unchanged from 3.3.1, so a patch bump meets the release policy.

## What Changed

Bump the runtime version from 3.3.1 to 3.3.2 in the package metadata and lockfile. The branch starts from main at ad45b88, which includes PR #179. Merging this PR triggers the release workflow.

Validation passed: 1,576 unit and contract tests, lint, strict type checking, the HTTP release gate, and the wire release gate.

Signed-off-by: Douglas Ferreira <douglaseel@gmail.com>
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.

3 participants