reactor-runtime 3.3.0__tar.gz → 3.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/PKG-INFO +2 -2
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/pyproject.toml +2 -2
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/pyproject.toml.orig +2 -2
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/typespec.py +15 -0
- reactor_runtime-3.3.2/src/reactor_runtime/metrics/__init__.py +21 -0
- reactor_runtime-3.3.2/src/reactor_runtime/metrics/command.py +101 -0
- reactor_runtime-3.3.2/src/reactor_runtime/metrics/model.py +126 -0
- reactor_runtime-3.3.2/src/reactor_runtime/metrics/registry.py +37 -0
- reactor_runtime-3.3.2/src/reactor_runtime/metrics/session.py +215 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/runner.py +43 -10
- reactor_runtime-3.3.2/src/reactor_runtime/runner/upload_resolution.py +118 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/acceptor.py +17 -2
- reactor_runtime-3.3.2/src/reactor_runtime/transport/webrtc/metrics.py +459 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/peer.py +41 -8
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/router.py +3 -1
- reactor_runtime-3.3.2/src/reactor_runtime/transport/webrtc/stats.py +141 -0
- reactor_runtime-3.3.0/src/reactor_runtime/metrics.py +0 -547
- reactor_runtime-3.3.0/src/reactor_runtime/transport/webrtc/stats.py +0 -80
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/LICENSE +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/NOTICE +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/README.md +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/codes.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/fields.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/model.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/naming.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/service.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/session.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/transport.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/values.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/event_stream.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/events.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/routes.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/server.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/spec.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/client.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/events/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/events/decorators.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/events/errors.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/events/messages.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/internal/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/internal/bridge.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/model/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/model/contract.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/model/schema.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/tracks/input.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/tracks/output.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/log.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/manifest.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/message_gateway.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/paths.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/base.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/common.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/v0/codec.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/v1/codec.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/py.typed +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/recording/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/recording/markers.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/recording/recorder.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/connection_manager.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/offer_epochs.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/state_machine.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/schema.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/serve.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/service.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/acceptor.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/router.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/__init__.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/config.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/connection.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/version.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/upload_store.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/common_pb2.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/common_pb2.pyi +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/control_pb2.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/control_pb2.pyi +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/data_pb2.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/data_pb2.pyi +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/model_pb2.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/model_pb2.pyi +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/platform_pb2.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/track_pb2.py +0 -0
- {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/track_pb2.pyi +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reactor-runtime
|
|
3
|
-
Version: 3.3.
|
|
3
|
+
Version: 3.3.2
|
|
4
4
|
Summary: A Python framework for building real-time, interactive video models
|
|
5
5
|
Author: Reactor
|
|
6
6
|
Author-email: Reactor <team@reactor.inc>
|
|
@@ -18,7 +18,7 @@ Requires-Dist: numpy>=2.1
|
|
|
18
18
|
Requires-Dist: prometheus-client>=0.26.0
|
|
19
19
|
Requires-Dist: protobuf>=7.35.1
|
|
20
20
|
Requires-Dist: pyyaml>=6.0.3
|
|
21
|
-
Requires-Dist: reactor-webrtc==0.
|
|
21
|
+
Requires-Dist: reactor-webrtc==0.17.0
|
|
22
22
|
Requires-Dist: uvicorn>=0.49.0
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
24
|
Project-URL: Source, https://github.com/reactor-team/reactor-runtime
|
|
@@ -92,7 +92,7 @@ pythonpath = ["."]
|
|
|
92
92
|
|
|
93
93
|
[project]
|
|
94
94
|
name = "reactor-runtime"
|
|
95
|
-
version = "3.3.
|
|
95
|
+
version = "3.3.2"
|
|
96
96
|
description = "A Python framework for building real-time, interactive video models"
|
|
97
97
|
readme = "README.md"
|
|
98
98
|
license = "Apache-2.0"
|
|
@@ -115,7 +115,7 @@ dependencies = [
|
|
|
115
115
|
"prometheus-client>=0.26.0",
|
|
116
116
|
"protobuf>=7.35.1",
|
|
117
117
|
"pyyaml>=6.0.3",
|
|
118
|
-
"reactor-webrtc==0.
|
|
118
|
+
"reactor-webrtc==0.17.0",
|
|
119
119
|
"uvicorn>=0.49.0",
|
|
120
120
|
]
|
|
121
121
|
|
|
@@ -20,7 +20,7 @@ version = "1.20260814.7"
|
|
|
20
20
|
|
|
21
21
|
[project]
|
|
22
22
|
name = "reactor-runtime"
|
|
23
|
-
version = "3.3.
|
|
23
|
+
version = "3.3.2"
|
|
24
24
|
description = "A Python framework for building real-time, interactive video models"
|
|
25
25
|
readme = "README.md"
|
|
26
26
|
license = "Apache-2.0"
|
|
@@ -41,7 +41,7 @@ dependencies = [
|
|
|
41
41
|
"prometheus-client>=0.26.0",
|
|
42
42
|
"protobuf>=7.35.1",
|
|
43
43
|
"pyyaml>=6.0.3",
|
|
44
|
-
"reactor-webrtc==0.
|
|
44
|
+
"reactor-webrtc==0.17.0",
|
|
45
45
|
"uvicorn>=0.49.0",
|
|
46
46
|
]
|
|
47
47
|
|
|
@@ -218,6 +218,11 @@ class ListSpec(TypeSpec):
|
|
|
218
218
|
def __init__(self, item: TypeSpec) -> None:
|
|
219
219
|
self._item = item
|
|
220
220
|
|
|
221
|
+
@property
|
|
222
|
+
def item(self) -> TypeSpec:
|
|
223
|
+
"""The type every element must fit."""
|
|
224
|
+
return self._item
|
|
225
|
+
|
|
221
226
|
def check(self, value: Any) -> str | None: # noqa: D102 — contract on the base
|
|
222
227
|
if not isinstance(value, list):
|
|
223
228
|
return f"expected array, got {type(value).__name__}"
|
|
@@ -240,6 +245,11 @@ class DictSpec(TypeSpec):
|
|
|
240
245
|
def __init__(self, value: TypeSpec) -> None:
|
|
241
246
|
self._value = value
|
|
242
247
|
|
|
248
|
+
@property
|
|
249
|
+
def value(self) -> TypeSpec:
|
|
250
|
+
"""The type every value must fit."""
|
|
251
|
+
return self._value
|
|
252
|
+
|
|
243
253
|
def check(self, value: Any) -> str | None: # noqa: D102 — contract on the base
|
|
244
254
|
if not isinstance(value, Mapping):
|
|
245
255
|
return f"expected object, got {type(value).__name__}"
|
|
@@ -266,6 +276,11 @@ class DataclassSpec(TypeSpec):
|
|
|
266
276
|
self._fields = fields
|
|
267
277
|
self._required = required
|
|
268
278
|
|
|
279
|
+
@property
|
|
280
|
+
def fields(self) -> Mapping[str, TypeSpec]:
|
|
281
|
+
"""Each field's type, by field name."""
|
|
282
|
+
return self._fields
|
|
283
|
+
|
|
269
284
|
@classmethod
|
|
270
285
|
def build(cls, dataclass_type: type) -> DataclassSpec:
|
|
271
286
|
"""Resolve a dataclass into a spec over its fields."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Copyright (c) 2026 Reactor Technologies, Inc. All rights reserved.
|
|
2
|
+
"""Expose the shared registry and runtime metric groups."""
|
|
3
|
+
|
|
4
|
+
from prometheus_client import CONTENT_TYPE_LATEST
|
|
5
|
+
|
|
6
|
+
from reactor_runtime.metrics.command import UNKNOWN_COMMAND, CommandMetrics
|
|
7
|
+
from reactor_runtime.metrics.model import ModelMetrics
|
|
8
|
+
from reactor_runtime.metrics.registry import RuntimeMetrics
|
|
9
|
+
from reactor_runtime.metrics.session import MetricsRecorder
|
|
10
|
+
|
|
11
|
+
CONTENT_TYPE = CONTENT_TYPE_LATEST
|
|
12
|
+
"""The media type of a registry rendered in Prometheus text format."""
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CONTENT_TYPE",
|
|
16
|
+
"UNKNOWN_COMMAND",
|
|
17
|
+
"CommandMetrics",
|
|
18
|
+
"MetricsRecorder",
|
|
19
|
+
"ModelMetrics",
|
|
20
|
+
"RuntimeMetrics",
|
|
21
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Copyright (c) 2026 Reactor Technologies, Inc. All rights reserved.
|
|
2
|
+
"""Record command outcomes and ingress times."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
from prometheus_client import Counter, Histogram
|
|
10
|
+
|
|
11
|
+
from reactor_runtime.metrics.registry import RuntimeMetrics
|
|
12
|
+
|
|
13
|
+
# Ingress is the runtime's own work, and it reaches the model in under a
|
|
14
|
+
# millisecond while the loop is free. The upper buckets are a loop that is
|
|
15
|
+
# starved, which is the condition the measurement exists to expose.
|
|
16
|
+
_COMMAND_INGRESS_BUCKETS = (0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
|
|
17
|
+
UNKNOWN_COMMAND = "unknown"
|
|
18
|
+
"""The command label for a name the model does not declare.
|
|
19
|
+
|
|
20
|
+
A client sends whatever name it likes, so the name on the wire is not a bounded
|
|
21
|
+
value and cannot be a label. Only the commands in the model's schema are bounded.
|
|
22
|
+
Every other name shares this one series, which keeps a client that spells a
|
|
23
|
+
command wrong in a loop from minting a series for each attempt.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CommandMetrics:
|
|
28
|
+
"""Records what a client asked the model to do, and how long the ask took.
|
|
29
|
+
|
|
30
|
+
Every command the runtime admits passes one choke point, which already
|
|
31
|
+
branches on the three outcomes a command can have: the model accepted it, the
|
|
32
|
+
contract rejected it, or it referenced an upload the store could not produce.
|
|
33
|
+
This class names those three outcomes as three methods, so the choke point
|
|
34
|
+
reads as the outcome it just decided and no label value appears at the call
|
|
35
|
+
site.
|
|
36
|
+
|
|
37
|
+
Ingress covers what the runtime does with a command before the model sees it:
|
|
38
|
+
the wait for the event loop, the decode, the contract validation, and the
|
|
39
|
+
enqueue. It stops when the command is enqueued, so it measures the runtime and
|
|
40
|
+
not the model — a handler that runs for a minute does not appear here. It also
|
|
41
|
+
excludes the wait for the bytes of an upload the command references, which is
|
|
42
|
+
the client's own latency and would otherwise bury the runtime's.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, metrics: RuntimeMetrics) -> None:
|
|
46
|
+
"""Declare the command instruments on the registry of *metrics*.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
metrics: The holder whose registry the instruments register against.
|
|
50
|
+
"""
|
|
51
|
+
self._commands = Counter(
|
|
52
|
+
"runtime_commands_total",
|
|
53
|
+
"Commands a client sent, by command and by what the runtime did with it.",
|
|
54
|
+
["command", "outcome"],
|
|
55
|
+
registry=metrics.registry,
|
|
56
|
+
)
|
|
57
|
+
self._ingress = Histogram(
|
|
58
|
+
"runtime_command_ingress_seconds",
|
|
59
|
+
"How long the runtime took to carry a command from the wire to the model.",
|
|
60
|
+
["command"],
|
|
61
|
+
buckets=_COMMAND_INGRESS_BUCKETS,
|
|
62
|
+
registry=metrics.registry,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def declare(self, commands: Iterable[str]) -> None:
|
|
66
|
+
"""Seed the series of every command the model declares.
|
|
67
|
+
|
|
68
|
+
A command nobody has sent yet has no series at all, which reads the same
|
|
69
|
+
as a command the model does not have. Seeding the declared names answers
|
|
70
|
+
"which of my commands do clients use" off one scrape, with a zero for the
|
|
71
|
+
ones nobody sends.
|
|
72
|
+
|
|
73
|
+
Only the two outcomes an ordinary command has are seeded. An unresolved
|
|
74
|
+
upload is a fault, and a row of zeroes for a fault that never happened
|
|
75
|
+
says nothing a missing series does not.
|
|
76
|
+
"""
|
|
77
|
+
for command in commands:
|
|
78
|
+
for outcome in ("accepted", "rejected"):
|
|
79
|
+
self._commands.labels(command=command, outcome=outcome)
|
|
80
|
+
|
|
81
|
+
def accepted(self, command: str, *, since: float) -> None:
|
|
82
|
+
"""Count a command the model took, and measure how long it waited."""
|
|
83
|
+
self._commands.labels(command=command, outcome="accepted").inc()
|
|
84
|
+
self._ingress.labels(command=command).observe(time.monotonic() - since)
|
|
85
|
+
|
|
86
|
+
def rejected(self, command: str, *, since: float) -> None:
|
|
87
|
+
"""Count a command the contract refused, and measure how long that took.
|
|
88
|
+
|
|
89
|
+
A rejection reaches the same choke point as an acceptance and costs the
|
|
90
|
+
same work, so it belongs in the ingress measurement.
|
|
91
|
+
"""
|
|
92
|
+
self._commands.labels(command=command, outcome="rejected").inc()
|
|
93
|
+
self._ingress.labels(command=command).observe(time.monotonic() - since)
|
|
94
|
+
|
|
95
|
+
def unresolved_upload(self, command: str) -> None:
|
|
96
|
+
"""Count a command dropped because an upload it references never arrived.
|
|
97
|
+
|
|
98
|
+
This one records no ingress. Ingress measures a command the runtime
|
|
99
|
+
carried to the model, and this one never got there.
|
|
100
|
+
"""
|
|
101
|
+
self._commands.labels(command=command, outcome="unresolved_upload").inc()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Copyright (c) 2026 Reactor Technologies, Inc. All rights reserved.
|
|
2
|
+
"""Record model load times and media output."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
|
|
9
|
+
from prometheus_client import Counter, Histogram
|
|
10
|
+
|
|
11
|
+
from reactor_runtime.metrics.registry import RuntimeMetrics
|
|
12
|
+
|
|
13
|
+
# A model reads its weights once. Small models load in seconds and large ones
|
|
14
|
+
# hold the process for minutes, which is the whole cold start a client waits on.
|
|
15
|
+
_MODEL_LOAD_BUCKETS = (1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0)
|
|
16
|
+
# The boundaries around a frame period cover a model that emits one frame at a
|
|
17
|
+
# time: 33ms is 30fps, and 67ms is half of it. The higher ones are a model that
|
|
18
|
+
# emits a batch at a time, whose gaps are the play-out duration of a batch, and
|
|
19
|
+
# the top of the range is a stall either of them would feel as a freeze.
|
|
20
|
+
_EMIT_INTERVAL_BUCKETS = (0.005, 0.01, 0.02, 0.033, 0.067, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ModelMetrics:
|
|
24
|
+
"""Records how long the model took to load and what it emits.
|
|
25
|
+
|
|
26
|
+
The facts the runtime knows about a model without looking inside it. The load
|
|
27
|
+
is the cold start a client waits through before the process can serve
|
|
28
|
+
anything. The emitted media is the output the model produces, counted in
|
|
29
|
+
frames, so the rate of the counter is the frame rate the model sustains, and
|
|
30
|
+
timed between emissions, so a stall the average frame rate would absorb is
|
|
31
|
+
still visible.
|
|
32
|
+
|
|
33
|
+
Nothing here measures the model's compute. A frame rate that falls is
|
|
34
|
+
visible, and why it fell belongs to the model author's own tooling.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
metrics: RuntimeMetrics,
|
|
40
|
+
*,
|
|
41
|
+
clock: Callable[[], float] = time.monotonic,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Declare the model instruments on the registry of *metrics*."""
|
|
44
|
+
self._clock = clock
|
|
45
|
+
self._load = Histogram(
|
|
46
|
+
"runtime_model_load_seconds",
|
|
47
|
+
"How long the model took to come up, from the import to a running model.",
|
|
48
|
+
["outcome"],
|
|
49
|
+
buckets=_MODEL_LOAD_BUCKETS,
|
|
50
|
+
registry=metrics.registry,
|
|
51
|
+
)
|
|
52
|
+
self._frames = Counter(
|
|
53
|
+
"runtime_media_frames_total",
|
|
54
|
+
"Frames the model emitted, by output track.",
|
|
55
|
+
["track"],
|
|
56
|
+
registry=metrics.registry,
|
|
57
|
+
)
|
|
58
|
+
self._interval = Histogram(
|
|
59
|
+
"runtime_media_emit_interval_seconds",
|
|
60
|
+
"Wall-clock time between one emission on an output track and the next.",
|
|
61
|
+
["track"],
|
|
62
|
+
buckets=_EMIT_INTERVAL_BUCKETS,
|
|
63
|
+
registry=metrics.registry,
|
|
64
|
+
)
|
|
65
|
+
self._last_emit: dict[str, float] = {}
|
|
66
|
+
|
|
67
|
+
def declare(self, tracks: Iterable[str]) -> None:
|
|
68
|
+
"""Seed the frame count of every output track the model declares.
|
|
69
|
+
|
|
70
|
+
A track the model has emitted nothing on reads zero rather than being
|
|
71
|
+
absent, which is what tells a silent track apart from a track this model
|
|
72
|
+
does not have.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
tracks: The names of the model's outbound media tracks.
|
|
76
|
+
"""
|
|
77
|
+
for track in tracks:
|
|
78
|
+
self._frames.labels(track=track)
|
|
79
|
+
|
|
80
|
+
def session_started(self) -> None:
|
|
81
|
+
"""Start the emission timing over for a new session.
|
|
82
|
+
|
|
83
|
+
The span between the last frame one session emitted and the first frame
|
|
84
|
+
of the next is a model waiting for a client, not a model that stalled, so
|
|
85
|
+
no interval crosses a session boundary.
|
|
86
|
+
"""
|
|
87
|
+
self._last_emit.clear()
|
|
88
|
+
|
|
89
|
+
def loaded(self, *, since: float) -> None:
|
|
90
|
+
"""Measure a model that came up and is ready to serve."""
|
|
91
|
+
self._load.labels(outcome="ok").observe(self._clock() - since)
|
|
92
|
+
|
|
93
|
+
def load_failed(self, *, since: float) -> None:
|
|
94
|
+
"""Measure a model that failed to come up.
|
|
95
|
+
|
|
96
|
+
A failed load is terminal for the process, so this is observed at most
|
|
97
|
+
once and a scrape that catches it reports how long the process spent
|
|
98
|
+
before it gave up.
|
|
99
|
+
"""
|
|
100
|
+
self._load.labels(outcome="failed").observe(self._clock() - since)
|
|
101
|
+
|
|
102
|
+
def emitted(self, track: str, frames: int) -> None:
|
|
103
|
+
"""Count the frames one emission carried on *track* and time the gap to it.
|
|
104
|
+
|
|
105
|
+
Counted in frames rather than in emissions because the model batches: one
|
|
106
|
+
emission can carry a whole batch of video frames, and a counter of
|
|
107
|
+
emissions would report a rate lower than the true frame rate by the size
|
|
108
|
+
of the batch.
|
|
109
|
+
|
|
110
|
+
The gap to the previous emission is measured as it stands, undivided by
|
|
111
|
+
the batch, so a model that emits a batch at a time has a baseline of the
|
|
112
|
+
play-out duration of one batch and a stall reads as an excursion above it.
|
|
113
|
+
The rate of the counter gives the frame rate the model averages; a rate
|
|
114
|
+
cannot show that half a minute of it arrived in one burst, and the gaps
|
|
115
|
+
can.
|
|
116
|
+
|
|
117
|
+
Called on the model thread at the frame rate of the model. Each
|
|
118
|
+
instrument takes a lock per call, which is cheap next to producing the
|
|
119
|
+
frame.
|
|
120
|
+
"""
|
|
121
|
+
now = self._clock()
|
|
122
|
+
previous = self._last_emit.get(track)
|
|
123
|
+
if previous is not None:
|
|
124
|
+
self._interval.labels(track=track).observe(now - previous)
|
|
125
|
+
self._last_emit[track] = now
|
|
126
|
+
self._frames.labels(track=track).inc(frames)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Copyright (c) 2026 Reactor Technologies, Inc. All rights reserved.
|
|
2
|
+
"""Own the shared Prometheus registry and process identity."""
|
|
3
|
+
|
|
4
|
+
from prometheus_client import CollectorRegistry, Info, generate_latest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RuntimeMetrics:
|
|
8
|
+
"""The registry one process observes on, and the identity it publishes.
|
|
9
|
+
|
|
10
|
+
Every instrument in the process registers against :attr:`registry`, and
|
|
11
|
+
:meth:`render` serves it. The process states its own version and model once,
|
|
12
|
+
as a ``runtime_info`` series, so no other instrument needs to repeat
|
|
13
|
+
that identity on each observation.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *, version: str, model: str) -> None:
|
|
17
|
+
"""Create an empty registry and publish the identity of the process on it.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
version: The version of the runtime that runs in this process.
|
|
21
|
+
model: The reference of the model this process hosts.
|
|
22
|
+
"""
|
|
23
|
+
self.registry = CollectorRegistry()
|
|
24
|
+
Info(
|
|
25
|
+
"runtime",
|
|
26
|
+
"The version of the runtime and the model this process hosts.",
|
|
27
|
+
registry=self.registry,
|
|
28
|
+
).info({"version": version, "model": model})
|
|
29
|
+
|
|
30
|
+
def render(self) -> bytes:
|
|
31
|
+
"""Render the registry in the Prometheus text format.
|
|
32
|
+
|
|
33
|
+
The registry exists before the model starts to load, so a scrape during a
|
|
34
|
+
slow load answers with the identity of the process and every observation
|
|
35
|
+
made so far.
|
|
36
|
+
"""
|
|
37
|
+
return generate_latest(self.registry)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Copyright (c) 2026 Reactor Technologies, Inc. All rights reserved.
|
|
2
|
+
"""Record session transitions and connection counts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from prometheus_client import Counter, Gauge, Histogram
|
|
11
|
+
|
|
12
|
+
from reactor_runtime.core import JOURNAL_EVENTS, EndReason, SessionEvent, SessionState, Transition
|
|
13
|
+
from reactor_runtime.metrics.registry import RuntimeMetrics
|
|
14
|
+
|
|
15
|
+
# A session runs for seconds when a client fails to arrive and for hours when one
|
|
16
|
+
# stays, so the buckets span both and stay coarse in between.
|
|
17
|
+
_SESSION_DURATION_BUCKETS = (1.0, 5.0, 15.0, 30.0, 60.0, 300.0, 600.0, 1800.0, 3600.0, 7200.0)
|
|
18
|
+
# A client that already holds an offer connects in under a second. The orphan
|
|
19
|
+
# timeout ends a client-less session at a minute, so the last boundary sits
|
|
20
|
+
# there. A session no client ever joined observes nothing at all.
|
|
21
|
+
_FIRST_CLIENT_BUCKETS = (0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 60.0)
|
|
22
|
+
# Teardown closes the wires and stops the recorder. It is fast, and the tail is
|
|
23
|
+
# the interesting part, so the buckets sit below the grace period.
|
|
24
|
+
_TEARDOWN_BUCKETS = (0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _reason_label(detail: Mapping[str, Any]) -> str:
|
|
28
|
+
"""Return the end reason a move carries, as a bounded label value.
|
|
29
|
+
|
|
30
|
+
The runtime authors every reason, and a move that names none is a plain stop —
|
|
31
|
+
the same reading the runner's own dispatch takes. The type guard holds the
|
|
32
|
+
label to the five :class:`EndReason` values whatever a caller puts in the
|
|
33
|
+
detail, because one unbounded label value costs a series forever.
|
|
34
|
+
"""
|
|
35
|
+
reason = detail.get("reason", EndReason.STOPPED)
|
|
36
|
+
return reason.value if isinstance(reason, EndReason) else EndReason.STOPPED.value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MetricsRecorder:
|
|
40
|
+
"""Records the session lifecycle on the registry, one transition at a time.
|
|
41
|
+
|
|
42
|
+
Subscribes to the session state machine and reads the moves that pass. The
|
|
43
|
+
machine already carries every session fact — a start, each connection, an
|
|
44
|
+
error, a teardown, an eviction — so one listener over it is the whole session
|
|
45
|
+
surface, and no component of the runtime calls an instrument inline.
|
|
46
|
+
|
|
47
|
+
The recorder keeps the little state a duration needs: when the session
|
|
48
|
+
started, whether a client has arrived yet, and when teardown began. It reads
|
|
49
|
+
a monotonic clock rather than the wall-clock stamp on the transition, so a
|
|
50
|
+
duration survives a clock adjustment.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
metrics: RuntimeMetrics,
|
|
56
|
+
*,
|
|
57
|
+
state: SessionState = SessionState.CREATED,
|
|
58
|
+
clock: Callable[[], float] = time.monotonic,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Declare the session instruments on the registry of *metrics*.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
metrics: The holder whose registry the instruments register against.
|
|
64
|
+
state: The state the session is in now, published at once so a scrape
|
|
65
|
+
before the first move still reports where the session sits.
|
|
66
|
+
clock: The monotonic source durations are measured against.
|
|
67
|
+
"""
|
|
68
|
+
registry = metrics.registry
|
|
69
|
+
self._clock = clock
|
|
70
|
+
self._sessions = Counter(
|
|
71
|
+
"runtime_sessions_total",
|
|
72
|
+
"Sessions that ended, by the reason they ended.",
|
|
73
|
+
["reason"],
|
|
74
|
+
registry=registry,
|
|
75
|
+
)
|
|
76
|
+
self._duration = Histogram(
|
|
77
|
+
"runtime_session_duration_seconds",
|
|
78
|
+
"How long a session ran, from its start to the end of its teardown.",
|
|
79
|
+
["reason"],
|
|
80
|
+
buckets=_SESSION_DURATION_BUCKETS,
|
|
81
|
+
registry=registry,
|
|
82
|
+
)
|
|
83
|
+
self._first_client = Histogram(
|
|
84
|
+
"runtime_session_time_to_first_client_seconds",
|
|
85
|
+
"How long a session waited for its first client to connect.",
|
|
86
|
+
buckets=_FIRST_CLIENT_BUCKETS,
|
|
87
|
+
registry=registry,
|
|
88
|
+
)
|
|
89
|
+
self._teardown = Histogram(
|
|
90
|
+
"runtime_session_teardown_seconds",
|
|
91
|
+
"How long a session took to unwind, from the start of teardown to a ready model.",
|
|
92
|
+
buckets=_TEARDOWN_BUCKETS,
|
|
93
|
+
registry=registry,
|
|
94
|
+
)
|
|
95
|
+
self._opened = Counter(
|
|
96
|
+
"runtime_connections_opened_total",
|
|
97
|
+
"Client connections that opened.",
|
|
98
|
+
registry=registry,
|
|
99
|
+
)
|
|
100
|
+
self._closed = Counter(
|
|
101
|
+
"runtime_connections_closed_total",
|
|
102
|
+
"Client connections a client itself closed.",
|
|
103
|
+
registry=registry,
|
|
104
|
+
)
|
|
105
|
+
self._active = Gauge(
|
|
106
|
+
"runtime_connections_active",
|
|
107
|
+
"Client connections attached to the session right now.",
|
|
108
|
+
registry=registry,
|
|
109
|
+
)
|
|
110
|
+
self._session_state = Gauge(
|
|
111
|
+
"runtime_session_state",
|
|
112
|
+
"The state the session is in, as one series per state holding 1 or 0.",
|
|
113
|
+
["state"],
|
|
114
|
+
registry=registry,
|
|
115
|
+
)
|
|
116
|
+
self._errors = Counter(
|
|
117
|
+
"runtime_session_errors_total",
|
|
118
|
+
"Errors the session recorded. The message stays in the journal.",
|
|
119
|
+
registry=registry,
|
|
120
|
+
)
|
|
121
|
+
self._started_at: float | None = None
|
|
122
|
+
self._client_seen = False
|
|
123
|
+
self._closing_at: float | None = None
|
|
124
|
+
self._live = 0
|
|
125
|
+
# Declare the series a query reads before the first event of its kind, so
|
|
126
|
+
# a fresh process answers a rate with zero rather than with nothing.
|
|
127
|
+
for reason in EndReason:
|
|
128
|
+
self._sessions.labels(reason=reason.value)
|
|
129
|
+
self._active.set(0)
|
|
130
|
+
self._publish_state(state)
|
|
131
|
+
|
|
132
|
+
def observe(self, transition: Transition) -> None:
|
|
133
|
+
"""Fold one session move into the instruments.
|
|
134
|
+
|
|
135
|
+
Runs on every legal move, including the journal self-loops. A move that
|
|
136
|
+
changes no state records only what its event says, so the state gauge and
|
|
137
|
+
the state-entry durations stay true while a segment or an error rides out
|
|
138
|
+
during teardown.
|
|
139
|
+
"""
|
|
140
|
+
now = self._clock()
|
|
141
|
+
event = transition.event
|
|
142
|
+
entered = transition.from_state is not transition.to_state
|
|
143
|
+
self._fold_connections(event)
|
|
144
|
+
if entered:
|
|
145
|
+
self._publish_state(transition.to_state)
|
|
146
|
+
if event is SessionEvent.ERROR:
|
|
147
|
+
self._errors.inc()
|
|
148
|
+
if event is SessionEvent.CONNECTION_OPENED:
|
|
149
|
+
self._note_first_client(now)
|
|
150
|
+
if transition.is_session_start:
|
|
151
|
+
self._started_at = now
|
|
152
|
+
self._client_seen = False
|
|
153
|
+
if entered and transition.to_state is SessionState.CLOSING:
|
|
154
|
+
self._closing_at = now
|
|
155
|
+
if transition.is_session_end:
|
|
156
|
+
if self._closing_at is not None:
|
|
157
|
+
self._teardown.observe(now - self._closing_at)
|
|
158
|
+
self._end_session(_reason_label(transition.detail), now)
|
|
159
|
+
if entered and event is SessionEvent.EVICTION:
|
|
160
|
+
# An eviction is terminal from anywhere and skips teardown, so the
|
|
161
|
+
# session it interrupted ends here rather than on a cleanup move.
|
|
162
|
+
self._end_session(_reason_label(transition.detail), now)
|
|
163
|
+
|
|
164
|
+
def _note_first_client(self, now: float) -> None:
|
|
165
|
+
"""Measure the wait for the first client of the session, once per session."""
|
|
166
|
+
if self._started_at is None or self._client_seen:
|
|
167
|
+
return
|
|
168
|
+
self._first_client.observe(now - self._started_at)
|
|
169
|
+
self._client_seen = True
|
|
170
|
+
|
|
171
|
+
def _end_session(self, reason: str, now: float) -> None:
|
|
172
|
+
"""Count a session that ended and measure how long it ran.
|
|
173
|
+
|
|
174
|
+
A move that ends no session — an eviction while the model sat idle, or a
|
|
175
|
+
model that failed to load — counts nothing, because no session ran.
|
|
176
|
+
"""
|
|
177
|
+
if self._started_at is not None:
|
|
178
|
+
self._duration.labels(reason=reason).observe(now - self._started_at)
|
|
179
|
+
self._sessions.labels(reason=reason).inc()
|
|
180
|
+
self._started_at = None
|
|
181
|
+
self._client_seen = False
|
|
182
|
+
self._closing_at = None
|
|
183
|
+
|
|
184
|
+
def _fold_connections(self, event: SessionEvent) -> None:
|
|
185
|
+
"""Count each connection fact and hold the gauge at the live count.
|
|
186
|
+
|
|
187
|
+
Teardown closes every wire wholesale and reports no per-connection loss,
|
|
188
|
+
so a move that is neither a connection fact nor a self-loop clears the
|
|
189
|
+
count. That is the state machine's own rule for its live connections, and
|
|
190
|
+
following it keeps the gauge from holding the connections a finished
|
|
191
|
+
session left behind.
|
|
192
|
+
|
|
193
|
+
The counters follow the same rule, so every connection that opened counts
|
|
194
|
+
on one and only the ones a client closed itself count on the other. Their
|
|
195
|
+
difference is the number teardown reaped, and it grows with the sessions
|
|
196
|
+
the process has served rather than showing a leak.
|
|
197
|
+
"""
|
|
198
|
+
if event is SessionEvent.CONNECTION_OPENED:
|
|
199
|
+
self._opened.inc()
|
|
200
|
+
self._live += 1
|
|
201
|
+
elif event is SessionEvent.CONNECTION_CLOSED:
|
|
202
|
+
self._closed.inc()
|
|
203
|
+
self._live = max(0, self._live - 1)
|
|
204
|
+
elif event is SessionEvent.CONNECTION_ANSWERED or event in JOURNAL_EVENTS:
|
|
205
|
+
return
|
|
206
|
+
else:
|
|
207
|
+
self._live = 0
|
|
208
|
+
self._active.set(self._live)
|
|
209
|
+
|
|
210
|
+
def _publish_state(self, state: SessionState) -> None:
|
|
211
|
+
"""Raise the series of the current state and drop every other one."""
|
|
212
|
+
for member in SessionState:
|
|
213
|
+
self._session_state.labels(state=member.name.lower()).set(
|
|
214
|
+
1.0 if member is state else 0.0
|
|
215
|
+
)
|