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.
Files changed (106) hide show
  1. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/PKG-INFO +2 -2
  2. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/pyproject.toml +2 -2
  3. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/pyproject.toml.orig +2 -2
  4. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/typespec.py +15 -0
  5. reactor_runtime-3.3.2/src/reactor_runtime/metrics/__init__.py +21 -0
  6. reactor_runtime-3.3.2/src/reactor_runtime/metrics/command.py +101 -0
  7. reactor_runtime-3.3.2/src/reactor_runtime/metrics/model.py +126 -0
  8. reactor_runtime-3.3.2/src/reactor_runtime/metrics/registry.py +37 -0
  9. reactor_runtime-3.3.2/src/reactor_runtime/metrics/session.py +215 -0
  10. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/runner.py +43 -10
  11. reactor_runtime-3.3.2/src/reactor_runtime/runner/upload_resolution.py +118 -0
  12. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/acceptor.py +17 -2
  13. reactor_runtime-3.3.2/src/reactor_runtime/transport/webrtc/metrics.py +459 -0
  14. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/peer.py +41 -8
  15. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/router.py +3 -1
  16. reactor_runtime-3.3.2/src/reactor_runtime/transport/webrtc/stats.py +141 -0
  17. reactor_runtime-3.3.0/src/reactor_runtime/metrics.py +0 -547
  18. reactor_runtime-3.3.0/src/reactor_runtime/transport/webrtc/stats.py +0 -80
  19. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/LICENSE +0 -0
  20. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/NOTICE +0 -0
  21. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/README.md +0 -0
  22. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/__init__.py +0 -0
  23. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/codes.py +0 -0
  24. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/__init__.py +0 -0
  25. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/fields.py +0 -0
  26. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/model.py +0 -0
  27. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/naming.py +0 -0
  28. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/service.py +0 -0
  29. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/session.py +0 -0
  30. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/transport.py +0 -0
  31. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/core/values.py +0 -0
  32. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/event_stream.py +0 -0
  33. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/__init__.py +0 -0
  34. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/events.py +0 -0
  35. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/routes.py +0 -0
  36. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/server.py +0 -0
  37. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/http/spec.py +0 -0
  38. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/__init__.py +0 -0
  39. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/client.py +0 -0
  40. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/events/__init__.py +0 -0
  41. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/events/decorators.py +0 -0
  42. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/events/errors.py +0 -0
  43. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/events/messages.py +0 -0
  44. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  45. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/internal/bridge.py +0 -0
  46. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  47. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  48. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/model/__init__.py +0 -0
  49. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/model/contract.py +0 -0
  50. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  51. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/model/schema.py +0 -0
  52. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  53. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  54. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  55. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  56. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  57. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  58. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/tracks/input.py +0 -0
  59. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/interface/tracks/output.py +0 -0
  60. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/log.py +0 -0
  61. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/manifest.py +0 -0
  62. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/message_gateway.py +0 -0
  63. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/paths.py +0 -0
  64. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/__init__.py +0 -0
  65. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/base.py +0 -0
  66. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/common.py +0 -0
  67. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  68. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/v0/codec.py +0 -0
  69. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  70. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  71. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/py.typed +0 -0
  72. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/recording/__init__.py +0 -0
  73. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  74. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/recording/markers.py +0 -0
  75. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/recording/recorder.py +0 -0
  76. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/__init__.py +0 -0
  77. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/connection_manager.py +0 -0
  78. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/offer_epochs.py +0 -0
  79. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/runner/state_machine.py +0 -0
  80. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/schema.py +0 -0
  81. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/serve.py +0 -0
  82. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/service.py +0 -0
  83. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/__init__.py +0 -0
  84. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/acceptor.py +0 -0
  85. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/router.py +0 -0
  86. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/__init__.py +0 -0
  87. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/config.py +0 -0
  88. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/connection.py +0 -0
  89. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  90. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
  91. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
  92. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
  93. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  94. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_runtime/upload_store.py +0 -0
  95. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/common_pb2.py +0 -0
  96. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  97. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/control_pb2.py +0 -0
  98. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/control_pb2.pyi +0 -0
  99. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/data_pb2.py +0 -0
  100. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  101. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/model_pb2.py +0 -0
  102. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  103. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/platform_pb2.py +0 -0
  104. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
  105. {reactor_runtime-3.3.0 → reactor_runtime-3.3.2}/src/reactor_wire/v1/track_pb2.py +0 -0
  106. {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.0
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.14.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.0"
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.14.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.0"
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.14.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
+ )