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 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)