sgnmon 0.1.0__py3-none-any.whl
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.
- sgnmon/__init__.py +62 -0
- sgnmon/_version.py +24 -0
- sgnmon/cli.py +256 -0
- sgnmon/elements.py +201 -0
- sgnmon/health.py +538 -0
- sgnmon/metrics.py +237 -0
- sgnmon/monitor.py +685 -0
- sgnmon/probes.py +776 -0
- sgnmon/py.typed +0 -0
- sgnmon/server.py +376 -0
- sgnmon/static/dashboard.css +219 -0
- sgnmon/static/dashboard.html +38 -0
- sgnmon/static/dashboard.js +963 -0
- sgnmon/tests/__init__.py +0 -0
- sgnmon/tests/helpers.py +169 -0
- sgnmon/tests/js/harness.js +431 -0
- sgnmon/tests/js/stubs.js +62 -0
- sgnmon/tests/test_browser.py +172 -0
- sgnmon/tests/test_cli.py +93 -0
- sgnmon/tests/test_composed.py +41 -0
- sgnmon/tests/test_dashboard.py +48 -0
- sgnmon/tests/test_elements.py +237 -0
- sgnmon/tests/test_health.py +357 -0
- sgnmon/tests/test_metrics.py +126 -0
- sgnmon/tests/test_monitor.py +230 -0
- sgnmon/tests/test_probes.py +221 -0
- sgnmon/tests/test_server.py +196 -0
- sgnmon-0.1.0.dist-info/METADATA +196 -0
- sgnmon-0.1.0.dist-info/RECORD +32 -0
- sgnmon-0.1.0.dist-info/WHEEL +4 -0
- sgnmon-0.1.0.dist-info/entry_points.txt +2 -0
- sgnmon-0.1.0.dist-info/licenses/LICENSE +195 -0
sgnmon/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Copyright (c) 2026, California Institute of Technology and contributors
|
|
2
|
+
#
|
|
3
|
+
# You should have received a copy of the licensing terms for this
|
|
4
|
+
# software included in the file "LICENSE" located in the top-level
|
|
5
|
+
# directory of this package. If you did not, you can view a copy at
|
|
6
|
+
# https://git.ligo.org/greg/sgnmon/-/raw/main/LICENSE
|
|
7
|
+
|
|
8
|
+
"""sgnmon --- monitoring and metrics for SGN streaming pipelines."""
|
|
9
|
+
|
|
10
|
+
from sgnmon.elements import EventLatency, MonitorSink, MonitorTransform
|
|
11
|
+
from sgnmon.health import (
|
|
12
|
+
CheckResult,
|
|
13
|
+
HealthReport,
|
|
14
|
+
MaxFrameAge,
|
|
15
|
+
MaxGapFraction,
|
|
16
|
+
MaxLatency,
|
|
17
|
+
MaxLatencyDrift,
|
|
18
|
+
MinThroughput,
|
|
19
|
+
NoEOS,
|
|
20
|
+
)
|
|
21
|
+
from sgnmon.metrics import MetricsMixin, MetricSpec
|
|
22
|
+
from sgnmon.monitor import Monitor
|
|
23
|
+
from sgnmon.probes import (
|
|
24
|
+
DEFAULT_WINDOWS,
|
|
25
|
+
FrameStats,
|
|
26
|
+
PadProbe,
|
|
27
|
+
ProbeSnapshot,
|
|
28
|
+
WindowStats,
|
|
29
|
+
frame_stats,
|
|
30
|
+
)
|
|
31
|
+
from sgnmon.server import MonitorServer, monitor_app
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from sgnmon._version import __version__
|
|
35
|
+
except ModuleNotFoundError: # pragma: no cover -- source tree without vcs build
|
|
36
|
+
__version__ = "0.0.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DEFAULT_WINDOWS",
|
|
40
|
+
"CheckResult",
|
|
41
|
+
"EventLatency",
|
|
42
|
+
"FrameStats",
|
|
43
|
+
"HealthReport",
|
|
44
|
+
"MaxFrameAge",
|
|
45
|
+
"MaxGapFraction",
|
|
46
|
+
"MaxLatency",
|
|
47
|
+
"MaxLatencyDrift",
|
|
48
|
+
"MetricSpec",
|
|
49
|
+
"MetricsMixin",
|
|
50
|
+
"MinThroughput",
|
|
51
|
+
"Monitor",
|
|
52
|
+
"MonitorServer",
|
|
53
|
+
"MonitorSink",
|
|
54
|
+
"MonitorTransform",
|
|
55
|
+
"NoEOS",
|
|
56
|
+
"PadProbe",
|
|
57
|
+
"ProbeSnapshot",
|
|
58
|
+
"WindowStats",
|
|
59
|
+
"__version__",
|
|
60
|
+
"frame_stats",
|
|
61
|
+
"monitor_app",
|
|
62
|
+
]
|
sgnmon/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
sgnmon/cli.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Copyright (c) 2026, California Institute of Technology and contributors
|
|
2
|
+
#
|
|
3
|
+
# You should have received a copy of the licensing terms for this
|
|
4
|
+
# software included in the file "LICENSE" located in the top-level
|
|
5
|
+
# directory of this package. If you did not, you can view a copy at
|
|
6
|
+
# https://git.ligo.org/greg/sgnmon/-/raw/main/LICENSE
|
|
7
|
+
|
|
8
|
+
"""Command line interface for sgnmon."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import runpy
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
help="Monitoring and metrics for SGN streaming pipelines.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def demo(
|
|
30
|
+
host: str = typer.Option("localhost", help="Interface to bind the server to."),
|
|
31
|
+
port: int = typer.Option(9090, help="Port for the monitoring server."),
|
|
32
|
+
rate: int = typer.Option(256, help="Sample rate of the fake data."),
|
|
33
|
+
gaps: bool = typer.Option(
|
|
34
|
+
True, help="Randomly emit gap buffers to exercise gap metrics."
|
|
35
|
+
),
|
|
36
|
+
duration: float = typer.Option(
|
|
37
|
+
0.0, help="Seconds of data to produce; 0 runs until Ctrl+C."
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Run a small monitored fake pipeline and serve its metrics.
|
|
41
|
+
|
|
42
|
+
Builds a diamond-shaped pipeline --- a fake source fanning out to an
|
|
43
|
+
amplifier, a resampler, and a monitoring transform, all converging
|
|
44
|
+
into a null sink --- monitors it with both attachment styles (taps
|
|
45
|
+
everywhere, plus the spliced MonitorTransform), and serves the live
|
|
46
|
+
dashboard, /metrics, /health, and /status until the pipeline ends.
|
|
47
|
+
"""
|
|
48
|
+
from sgn.apps import Pipeline
|
|
49
|
+
from sgn.sources import SignalEOS
|
|
50
|
+
from sgnts.sinks.null import NullSeriesSink
|
|
51
|
+
from sgnts.sources.fake_series import FakeSeriesSource
|
|
52
|
+
from sgnts.transforms.amplify import Amplify
|
|
53
|
+
from sgnts.transforms.resampler import Resampler
|
|
54
|
+
|
|
55
|
+
from sgnmon.elements import MonitorTransform
|
|
56
|
+
from sgnmon.monitor import Monitor
|
|
57
|
+
from sgnmon.server import MonitorServer
|
|
58
|
+
|
|
59
|
+
monitor = Monitor()
|
|
60
|
+
monitor.add_default_checks(
|
|
61
|
+
max_frame_age=10.0,
|
|
62
|
+
max_latency=10.0,
|
|
63
|
+
max_gap_fraction=0.9,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
source_kwargs: dict = {}
|
|
67
|
+
if duration:
|
|
68
|
+
source_kwargs["duration"] = duration
|
|
69
|
+
source = FakeSeriesSource(
|
|
70
|
+
name="src",
|
|
71
|
+
source_pad_names=["H1"],
|
|
72
|
+
rate=rate,
|
|
73
|
+
signal_type="white",
|
|
74
|
+
ngap=-1 if gaps else 0,
|
|
75
|
+
real_time=True,
|
|
76
|
+
**source_kwargs,
|
|
77
|
+
)
|
|
78
|
+
amp = Amplify(
|
|
79
|
+
name="amp", sink_pad_names=["H1"], source_pad_names=["H1"], factor=2.0
|
|
80
|
+
)
|
|
81
|
+
resamp = Resampler(
|
|
82
|
+
name="resamp",
|
|
83
|
+
sink_pad_names=["H1"],
|
|
84
|
+
source_pad_names=["H1"],
|
|
85
|
+
inrate=rate,
|
|
86
|
+
outrate=rate // 2,
|
|
87
|
+
)
|
|
88
|
+
mon = MonitorTransform(name="mon", sink_pad_names=["H1"], monitor=monitor)
|
|
89
|
+
sink = NullSeriesSink(name="snk", sink_pad_names=["amp", "resamp", "mon"])
|
|
90
|
+
|
|
91
|
+
pipeline = Pipeline()
|
|
92
|
+
pipeline.insert(source, amp, resamp, mon, sink)
|
|
93
|
+
pipeline.link(
|
|
94
|
+
{
|
|
95
|
+
"amp:snk:H1": "src:src:H1",
|
|
96
|
+
"resamp:snk:H1": "src:src:H1",
|
|
97
|
+
"mon:snk:H1": "src:src:H1",
|
|
98
|
+
"snk:snk:amp": "amp:src:H1",
|
|
99
|
+
"snk:snk:resamp": "resamp:src:H1",
|
|
100
|
+
"snk:snk:mon": "mon:src:H1",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
# the mon element records itself; tap everything else
|
|
104
|
+
monitor.tap(pipeline, exclude=r"^mon:")
|
|
105
|
+
|
|
106
|
+
with MonitorServer(monitor, host=host, port=port) as server:
|
|
107
|
+
typer.echo(f"dashboard: {server.url}/")
|
|
108
|
+
typer.echo(f"metrics: {server.url}/metrics")
|
|
109
|
+
typer.echo(f"health: {server.url}/health")
|
|
110
|
+
typer.echo(f"status: {server.url}/status")
|
|
111
|
+
with SignalEOS():
|
|
112
|
+
pipeline.run()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command(
|
|
116
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
|
|
117
|
+
)
|
|
118
|
+
def run(
|
|
119
|
+
ctx: typer.Context,
|
|
120
|
+
host: str = typer.Option("localhost", help="Interface to bind the server to."),
|
|
121
|
+
port: int = typer.Option(9090, help="Port for the monitoring server."),
|
|
122
|
+
pad_types: str = typer.Option(
|
|
123
|
+
"src,snk,adp",
|
|
124
|
+
help="Comma-separated observation points to tap: src, snk, adp.",
|
|
125
|
+
),
|
|
126
|
+
include: str | None = typer.Option(None, help="Only tap pads matching this regex."),
|
|
127
|
+
exclude: str | None = typer.Option(None, help="Skip pads matching this regex."),
|
|
128
|
+
module: bool = typer.Option(
|
|
129
|
+
False, "--module", "-m", help="Treat COMMAND as a python module name."
|
|
130
|
+
),
|
|
131
|
+
process_metrics: bool = typer.Option(
|
|
132
|
+
False,
|
|
133
|
+
help="Also export process CPU/memory/fd metrics (Linux only).",
|
|
134
|
+
),
|
|
135
|
+
max_frame_age: float = typer.Option(
|
|
136
|
+
60.0, help="Health: max seconds since last frame; 0 disables."
|
|
137
|
+
),
|
|
138
|
+
max_latency: float = typer.Option(
|
|
139
|
+
0.0, help="Health: max frame latency in seconds; 0 disables."
|
|
140
|
+
),
|
|
141
|
+
max_gap_fraction: float = typer.Option(
|
|
142
|
+
0.0, help="Health: max gap fraction over the window; 0 disables."
|
|
143
|
+
),
|
|
144
|
+
min_throughput: float = typer.Option(
|
|
145
|
+
0.9,
|
|
146
|
+
help="Health: min fraction of expected throughput; 0 disables.",
|
|
147
|
+
),
|
|
148
|
+
max_latency_drift: float = typer.Option(
|
|
149
|
+
10.0,
|
|
150
|
+
help="Health: max latency rise over the long-window baseline; 0 disables.",
|
|
151
|
+
),
|
|
152
|
+
warmup: float = typer.Option(
|
|
153
|
+
0.0,
|
|
154
|
+
help="Health: extra grace period before checks apply, in seconds; "
|
|
155
|
+
"checks already self-pace their startup, so 0 is usually right.",
|
|
156
|
+
),
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Run any sgn application with monitoring attached, no code changes.
|
|
159
|
+
|
|
160
|
+
Everything after "--" is the application command line, e.g.::
|
|
161
|
+
|
|
162
|
+
sgnmon run --port 9090 -- sgn-cal --config c.yaml --model m.npz
|
|
163
|
+
|
|
164
|
+
The application runs in this process; when it executes its pipeline,
|
|
165
|
+
the pipeline is tapped and the dashboard served for the duration of
|
|
166
|
+
the run. The command must be a python console script on PATH, a
|
|
167
|
+
path to a python script, or (with -m) a module name.
|
|
168
|
+
"""
|
|
169
|
+
from sgn.apps import Pipeline
|
|
170
|
+
|
|
171
|
+
from sgnmon.monitor import Monitor
|
|
172
|
+
from sgnmon.server import MonitorServer
|
|
173
|
+
|
|
174
|
+
command = list(ctx.args)
|
|
175
|
+
if not command:
|
|
176
|
+
typer.echo(
|
|
177
|
+
"no command given; usage: sgnmon run [options] -- <command...>", err=True
|
|
178
|
+
)
|
|
179
|
+
raise typer.Exit(2)
|
|
180
|
+
|
|
181
|
+
monitor = Monitor(process_metrics=process_metrics)
|
|
182
|
+
monitor.add_default_checks(
|
|
183
|
+
max_frame_age=max_frame_age or None,
|
|
184
|
+
max_latency=max_latency or None,
|
|
185
|
+
max_gap_fraction=max_gap_fraction or None,
|
|
186
|
+
min_throughput=min_throughput or None,
|
|
187
|
+
max_latency_drift=max_latency_drift or None,
|
|
188
|
+
warmup=warmup,
|
|
189
|
+
)
|
|
190
|
+
types = tuple(t.strip() for t in pad_types.split(",") if t.strip())
|
|
191
|
+
|
|
192
|
+
original_run = Pipeline.run
|
|
193
|
+
|
|
194
|
+
def monitored_run(self, *args, **kwargs):
|
|
195
|
+
monitor.tap(self, include=include, exclude=exclude, pad_types=types)
|
|
196
|
+
with MonitorServer(monitor, host=host, port=port) as server:
|
|
197
|
+
typer.echo(f"[sgnmon] dashboard: {server.url}/")
|
|
198
|
+
return original_run(self, *args, **kwargs)
|
|
199
|
+
|
|
200
|
+
Pipeline.run = monitored_run # type: ignore[method-assign]
|
|
201
|
+
|
|
202
|
+
sys.argv = command
|
|
203
|
+
exit_code = 0
|
|
204
|
+
try:
|
|
205
|
+
if module:
|
|
206
|
+
runpy.run_module(command[0], run_name="__main__", alter_sys=True)
|
|
207
|
+
else:
|
|
208
|
+
target = shutil.which(command[0]) or command[0]
|
|
209
|
+
if not Path(target).exists():
|
|
210
|
+
typer.echo(f"command not found: {command[0]}", err=True)
|
|
211
|
+
raise typer.Exit(2)
|
|
212
|
+
runpy.run_path(target, run_name="__main__")
|
|
213
|
+
except SystemExit as error: # console scripts exit via sys.exit(main())
|
|
214
|
+
if isinstance(error.code, int):
|
|
215
|
+
exit_code = error.code
|
|
216
|
+
elif error.code is not None:
|
|
217
|
+
typer.echo(str(error.code), err=True)
|
|
218
|
+
exit_code = 1
|
|
219
|
+
finally:
|
|
220
|
+
Pipeline.run = original_run # type: ignore[method-assign]
|
|
221
|
+
raise typer.Exit(exit_code)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.command()
|
|
225
|
+
def check(
|
|
226
|
+
url: str = typer.Argument(..., help="Base URL of a running sgnmon server."),
|
|
227
|
+
timeout: float = typer.Option(10.0, help="HTTP timeout in seconds."),
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Query the health endpoint of a running monitored pipeline.
|
|
230
|
+
|
|
231
|
+
Prints the JSON health report and exits 0 when healthy, 1 when any
|
|
232
|
+
check fails, and 2 when the server cannot be reached, so it can be used
|
|
233
|
+
directly as a monitoring plugin.
|
|
234
|
+
"""
|
|
235
|
+
target = url.rstrip("/") + "/health"
|
|
236
|
+
if not target.startswith(("http://", "https://")):
|
|
237
|
+
target = "http://" + target
|
|
238
|
+
try:
|
|
239
|
+
with urllib.request.urlopen(target, timeout=timeout) as response: # noqa: S310
|
|
240
|
+
code, body = response.status, response.read()
|
|
241
|
+
except urllib.error.HTTPError as error:
|
|
242
|
+
code, body = error.code, error.read()
|
|
243
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
244
|
+
typer.echo(f"cannot reach {target}: {error}", err=True)
|
|
245
|
+
raise typer.Exit(2) from error
|
|
246
|
+
try:
|
|
247
|
+
report = json.loads(body)
|
|
248
|
+
except json.JSONDecodeError as error:
|
|
249
|
+
typer.echo(f"invalid response from {target}: {error}", err=True)
|
|
250
|
+
raise typer.Exit(2) from error
|
|
251
|
+
typer.echo(json.dumps(report, indent=2))
|
|
252
|
+
raise typer.Exit(0 if code == 200 and report.get("ok") else 1)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
app()
|
sgnmon/elements.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Copyright (c) 2026, California Institute of Technology and contributors
|
|
2
|
+
#
|
|
3
|
+
# You should have received a copy of the licensing terms for this
|
|
4
|
+
# software included in the file "LICENSE" located in the top-level
|
|
5
|
+
# directory of this package. If you did not, you can view a copy at
|
|
6
|
+
# https://git.ligo.org/greg/sgnmon/-/raw/main/LICENSE
|
|
7
|
+
|
|
8
|
+
"""Pipeline elements that record monitoring metrics.
|
|
9
|
+
|
|
10
|
+
Two styles are provided:
|
|
11
|
+
|
|
12
|
+
* :class:`MonitorTransform` --- a transparent pass-through transform to
|
|
13
|
+
splice between two elements; frames are observed and forwarded unchanged.
|
|
14
|
+
* :class:`MonitorSink` --- a terminal sink for branches whose frames you
|
|
15
|
+
only want to monitor.
|
|
16
|
+
* :class:`EventLatency` --- a :class:`MonitorTransform` that additionally
|
|
17
|
+
measures per-event latency from a timestamp field, for event streams.
|
|
18
|
+
|
|
19
|
+
All work with any frame type. Time-series frames additionally get gap/data
|
|
20
|
+
time accounting and latency metrics (see :func:`sgnmon.probes.frame_stats`).
|
|
21
|
+
For monitoring an existing pipeline without touching its graph, see
|
|
22
|
+
:meth:`sgnmon.monitor.Monitor.tap`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
import gpstime
|
|
32
|
+
from sgn.base import SinkElement, SinkPad, SourcePad, TransformElement
|
|
33
|
+
|
|
34
|
+
from sgnmon.monitor import Monitor
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from sgn.frames import Frame
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(kw_only=True)
|
|
41
|
+
class MonitorTransform(TransformElement):
|
|
42
|
+
"""A pass-through transform that records monitoring metrics.
|
|
43
|
+
|
|
44
|
+
Frames received on each sink pad are observed and forwarded unchanged
|
|
45
|
+
on the source pad of the same name. If no source pad names are given,
|
|
46
|
+
they mirror the sink pad names.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
monitor : Monitor or None, optional
|
|
51
|
+
The monitor to record into; defaults to :meth:`Monitor.default`.
|
|
52
|
+
|
|
53
|
+
Examples
|
|
54
|
+
--------
|
|
55
|
+
Splice into a link between a source and a sink::
|
|
56
|
+
|
|
57
|
+
mon = MonitorTransform(name="mon", sink_pad_names=["H1"])
|
|
58
|
+
pipeline.insert(src, mon, snk)
|
|
59
|
+
pipeline.link({
|
|
60
|
+
"mon:snk:H1": "src:src:H1",
|
|
61
|
+
"snk:snk:H1": "mon:src:H1",
|
|
62
|
+
})
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
monitor: Monitor | None = None
|
|
66
|
+
|
|
67
|
+
def __post_init__(self):
|
|
68
|
+
if not self.source_pad_names:
|
|
69
|
+
self.source_pad_names = list(self.sink_pad_names)
|
|
70
|
+
super().__post_init__()
|
|
71
|
+
if sorted(self.source_pad_names) != sorted(self.sink_pad_names):
|
|
72
|
+
msg = (
|
|
73
|
+
"MonitorTransform forwards frames between identically named "
|
|
74
|
+
f"pads; got sink pads {list(self.sink_pad_names)} and source "
|
|
75
|
+
f"pads {list(self.source_pad_names)}"
|
|
76
|
+
)
|
|
77
|
+
raise ValueError(msg)
|
|
78
|
+
monitor = self.monitor if self.monitor is not None else Monitor.default()
|
|
79
|
+
self.monitor = monitor
|
|
80
|
+
self._frames: dict[str, Frame] = {}
|
|
81
|
+
self._probes = {pad: monitor.probe(pad) for pad in self.sink_pads}
|
|
82
|
+
|
|
83
|
+
def pull(self, pad: SinkPad, frame: Frame) -> None:
|
|
84
|
+
"""Observe a frame and stage it for forwarding.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
pad : sgn.base.SinkPad
|
|
89
|
+
The sink pad receiving the frame.
|
|
90
|
+
frame : Frame
|
|
91
|
+
The received frame.
|
|
92
|
+
"""
|
|
93
|
+
self._probes[pad].observe(frame)
|
|
94
|
+
self._frames[self.rsnks[pad]] = frame
|
|
95
|
+
|
|
96
|
+
def new(self, pad: SourcePad) -> Frame:
|
|
97
|
+
"""Forward the staged frame for the same-named sink pad.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
pad : sgn.base.SourcePad
|
|
102
|
+
The source pad producing the frame.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
Frame
|
|
107
|
+
The frame received on the same-named sink pad, unchanged.
|
|
108
|
+
"""
|
|
109
|
+
return self._frames[self.rsrcs[pad]]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(kw_only=True)
|
|
113
|
+
class EventLatency(MonitorTransform):
|
|
114
|
+
"""A pass-through transform that measures per-event latency.
|
|
115
|
+
|
|
116
|
+
Like :class:`MonitorTransform`, frames are observed and forwarded
|
|
117
|
+
unchanged. Additionally, each event in a frame's data is inspected
|
|
118
|
+
for a timestamp field, and current GPS time minus that timestamp is
|
|
119
|
+
recorded as the pad's latency (histogram, last-value gauge, and
|
|
120
|
+
windowed statistics). Use for event streams --- frames carrying
|
|
121
|
+
dicts or objects with a GPS time --- which have no time-series
|
|
122
|
+
accounting and would otherwise get no latency metrics.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
time_field : str, optional
|
|
127
|
+
Event field holding the event's GPS time, ``"time"`` by
|
|
128
|
+
default; read by key for mappings and by attribute otherwise.
|
|
129
|
+
Events missing the field, or with a non-numeric value, are
|
|
130
|
+
skipped.
|
|
131
|
+
monitor : Monitor or None, optional
|
|
132
|
+
The monitor to record into; defaults to :meth:`Monitor.default`.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
time_field: str = "time"
|
|
136
|
+
|
|
137
|
+
def pull(self, pad: SinkPad, frame: Frame) -> None:
|
|
138
|
+
"""Observe and stage a frame, recording per-event latencies.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
pad : sgn.base.SinkPad
|
|
143
|
+
The sink pad receiving the frame.
|
|
144
|
+
frame : Frame
|
|
145
|
+
The received frame; ``frame.data`` is either one event or a
|
|
146
|
+
list of events.
|
|
147
|
+
"""
|
|
148
|
+
super().pull(pad, frame)
|
|
149
|
+
if frame.is_gap or frame.data is None:
|
|
150
|
+
return
|
|
151
|
+
events = frame.data if isinstance(frame.data, list) else [frame.data]
|
|
152
|
+
now = gpstime.gpsnow()
|
|
153
|
+
probe = self._probes[pad]
|
|
154
|
+
for event in events:
|
|
155
|
+
if isinstance(event, Mapping):
|
|
156
|
+
value = event.get(self.time_field)
|
|
157
|
+
else:
|
|
158
|
+
value = getattr(event, self.time_field, None)
|
|
159
|
+
if value is None:
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
end_gps = float(value)
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
continue
|
|
165
|
+
probe.record_latency(now - end_gps, end_gps=end_gps)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(kw_only=True)
|
|
169
|
+
class MonitorSink(SinkElement):
|
|
170
|
+
"""A terminal sink that only records monitoring metrics.
|
|
171
|
+
|
|
172
|
+
Useful to terminate a monitoring branch, e.g. behind a tee, when no
|
|
173
|
+
other sink consumes the stream.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
monitor : Monitor or None, optional
|
|
178
|
+
The monitor to record into; defaults to :meth:`Monitor.default`.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
monitor: Monitor | None = None
|
|
182
|
+
|
|
183
|
+
def __post_init__(self):
|
|
184
|
+
super().__post_init__()
|
|
185
|
+
monitor = self.monitor if self.monitor is not None else Monitor.default()
|
|
186
|
+
self.monitor = monitor
|
|
187
|
+
self._probes = {pad: monitor.probe(pad) for pad in self.sink_pads}
|
|
188
|
+
|
|
189
|
+
def pull(self, pad: SinkPad, frame: Frame) -> None:
|
|
190
|
+
"""Observe a frame and mark end-of-stream when it carries EOS.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
pad : sgn.base.SinkPad
|
|
195
|
+
The sink pad receiving the frame.
|
|
196
|
+
frame : Frame
|
|
197
|
+
The received frame.
|
|
198
|
+
"""
|
|
199
|
+
self._probes[pad].observe(frame)
|
|
200
|
+
if frame.EOS:
|
|
201
|
+
self.mark_eos(pad)
|