mod-wsgi-telemetry 1.0.0.dev2__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.
@@ -0,0 +1,270 @@
1
+ """HTTP + WebSocket server that exposes the ingester's state.
2
+
3
+ Routes:
4
+ GET / static UI
5
+ GET /static/* static assets
6
+ GET /ws WebSocket push of new samples; initial message is a
7
+ snapshot of the rolling window so reloads don't lose
8
+ historical context.
9
+ GET /api/state JSON rendering of the rolling window (debugging aid).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import os
19
+ import signal
20
+ import sys
21
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
22
+ from pathlib import Path
23
+
24
+ from aiohttp import WSMsgType, web
25
+
26
+ from .ingest import Ingester
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ STATIC_DIR = Path(__file__).parent / "static"
31
+
32
+
33
+ def _resolve_version() -> str:
34
+ # The PyPi-published mod_wsgi-telemetry distribution carries the version;
35
+ # an uninstalled checkout (running straight out of the source tree) will
36
+ # raise PackageNotFoundError, in which case the UI just omits the suffix.
37
+ try:
38
+ return _pkg_version("mod_wsgi-telemetry")
39
+ except PackageNotFoundError:
40
+ return ""
41
+
42
+
43
+ def _parse_octal_mode(s: str) -> int:
44
+ try:
45
+ return int(s, 8)
46
+ except ValueError:
47
+ raise argparse.ArgumentTypeError(
48
+ f"socket-mode must be octal (e.g. 0660 or 660), got {s!r}")
49
+
50
+
51
+ async def index(request: web.Request) -> web.Response:
52
+ # Explicit --root-path wins; otherwise honour X-Forwarded-Prefix from
53
+ # the request, which mod_wsgi-express sets automatically for any
54
+ # mount-point proxy. With neither, base is empty and the JS builds
55
+ # root-anchored URLs (correct for direct access).
56
+ base = request.app["root_path"]
57
+ if not base:
58
+ base = _normalize_root_path(
59
+ request.headers.get("X-Forwarded-Prefix", ""))
60
+ pkg_version = request.app["version"]
61
+ inject = (
62
+ f"<script>window.TELEMETRY_BASE = {json.dumps(base)};"
63
+ f"window.TELEMETRY_VERSION = {json.dumps(pkg_version)};</script>\n"
64
+ )
65
+ html = request.app["index_html_raw"].replace(
66
+ "</head>", inject + "</head>", 1)
67
+ return web.Response(text=html, content_type="text/html")
68
+
69
+
70
+ def _normalize_root_path(value: str) -> str:
71
+ # "" / "/" mean no prefix; otherwise enforce one leading slash and no
72
+ # trailing slash so concatenation in the JS (`${BASE}/ws`) is safe.
73
+ value = value.strip()
74
+ if not value or value == "/":
75
+ return ""
76
+ if not value.startswith("/"):
77
+ value = "/" + value
78
+ return value.rstrip("/")
79
+
80
+
81
+ async def api_state(request: web.Request) -> web.Response:
82
+ ingester: Ingester = request.app["ingester"]
83
+ return web.json_response(ingester.snapshot())
84
+
85
+
86
+ async def api_slow_clear(request: web.Request) -> web.Response:
87
+ ingester: Ingester = request.app["ingester"]
88
+ ingester.clear_slow_requests()
89
+ return web.json_response({"ok": True})
90
+
91
+
92
+ async def websocket(request: web.Request) -> web.WebSocketResponse:
93
+ ingester: Ingester = request.app["ingester"]
94
+ ws = web.WebSocketResponse(heartbeat=30)
95
+ await ws.prepare(request)
96
+
97
+ websockets: set[web.WebSocketResponse] = request.app["websockets"]
98
+ websockets.add(ws)
99
+ q = ingester.subscribe()
100
+ try:
101
+ await ws.send_json(ingester.snapshot())
102
+
103
+ async def reader() -> None:
104
+ # Drains incoming frames; exits when the client closes
105
+ # the connection, errors, or the server calls ws.close()
106
+ # from close_websockets during shutdown. Reaching the
107
+ # end of this coroutine is the signal the writer loop
108
+ # below uses to know the ws is going away.
109
+ async for msg in ws:
110
+ if msg.type == WSMsgType.ERROR:
111
+ log.warning("ws client error: %s", ws.exception())
112
+ break
113
+
114
+ reader_task = asyncio.create_task(reader())
115
+
116
+ # Writer loop: race each q.get() against the reader exiting
117
+ # so a shutdown (or client disconnect) wakes the writer
118
+ # immediately rather than waiting for the queue to produce
119
+ # something. Previously this loop polled q.get() on a 30 s
120
+ # timeout, which delayed server shutdown by up to that long
121
+ # whenever no metrics were arriving.
122
+ while not ws.closed:
123
+ get_task = asyncio.create_task(q.get())
124
+ done, _ = await asyncio.wait(
125
+ {get_task, reader_task},
126
+ return_when=asyncio.FIRST_COMPLETED,
127
+ )
128
+ if reader_task in done:
129
+ get_task.cancel()
130
+ try:
131
+ await get_task
132
+ except (asyncio.CancelledError, Exception):
133
+ pass
134
+ break
135
+ payload = get_task.result()
136
+ try:
137
+ await ws.send_json(payload)
138
+ except ConnectionResetError:
139
+ break
140
+
141
+ if not reader_task.done():
142
+ reader_task.cancel()
143
+ try:
144
+ await reader_task
145
+ except (asyncio.CancelledError, Exception):
146
+ pass
147
+ finally:
148
+ websockets.discard(ws)
149
+ ingester.unsubscribe(q)
150
+ if not ws.closed:
151
+ await ws.close()
152
+
153
+ return ws
154
+
155
+
156
+ async def build_app(listen_spec: str, root_path: str = "",
157
+ socket_mode: int = 0o660,
158
+ socket_group: str | int | None = None) -> web.Application:
159
+ ingester = Ingester(listen_spec, socket_mode=socket_mode,
160
+ socket_group=socket_group)
161
+ app = web.Application()
162
+ app["ingester"] = ingester
163
+ app["websockets"] = set()
164
+ app["root_path"] = root_path
165
+ app["version"] = _resolve_version()
166
+ app["index_html_raw"] = (STATIC_DIR / "index.html").read_text(
167
+ encoding="utf-8")
168
+ app.router.add_get("/", index)
169
+ app.router.add_get("/ws", websocket)
170
+ app.router.add_get("/api/state", api_state)
171
+ app.router.add_post("/api/slow/clear", api_slow_clear)
172
+ app.router.add_static("/static/", STATIC_DIR)
173
+
174
+ async def start_ingester(_: web.Application) -> None:
175
+ app["ingester_task"] = asyncio.create_task(ingester.run())
176
+
177
+ async def close_websockets(_: web.Application) -> None:
178
+ for ws in list(app["websockets"]):
179
+ await ws.close(code=1001, message=b"server shutdown")
180
+
181
+ async def stop_ingester(_: web.Application) -> None:
182
+ task: asyncio.Task = app["ingester_task"]
183
+ task.cancel()
184
+ try:
185
+ await task
186
+ except asyncio.CancelledError:
187
+ pass
188
+
189
+ app.on_startup.append(start_ingester)
190
+ app.on_shutdown.append(close_websockets)
191
+ app.on_cleanup.append(stop_ingester)
192
+ return app
193
+
194
+
195
+ def main(argv: list[str] | None = None) -> int:
196
+ ap = argparse.ArgumentParser(
197
+ description="mod_wsgi telemetry ingester + live UI."
198
+ )
199
+ ap.add_argument("--listen", default="unix:/tmp/mod_wsgi-telemetry.sock",
200
+ help="unix:/path/to/sock (default: %(default)s)")
201
+ ap.add_argument("--socket-mode", type=_parse_octal_mode, default=0o660,
202
+ metavar="MODE",
203
+ help="Octal permission mode applied to the UNIX socket "
204
+ "after bind (default: 0660). Senders need write "
205
+ "permission; 0620 is the tighter alternative.")
206
+ ap.add_argument("--socket-group", default=None, metavar="GROUP",
207
+ help="Group name or numeric GID to chown the UNIX socket "
208
+ "to. With the default 0660 mode, every WSGI process "
209
+ "identity that needs to connect must be a member of "
210
+ "this group. Unset by default; the socket then keeps "
211
+ "the ingester user's primary group.")
212
+ ap.add_argument("--http-host", default="127.0.0.1")
213
+ ap.add_argument("--http-port", type=int, default=8888)
214
+ ap.add_argument("--root-path", default="",
215
+ help="URL prefix when fronted by a reverse proxy that "
216
+ "strips it (e.g. /ui). Used to build links and the "
217
+ "WebSocket URL in the served HTML. The proxy must "
218
+ "strip the prefix before the request reaches this "
219
+ "server. When unset, the X-Forwarded-Prefix request "
220
+ "header is honoured if present (mod_wsgi-express's "
221
+ "--proxy-mount-point sets it automatically).")
222
+ ap.add_argument("--log-level", default="INFO")
223
+ args = ap.parse_args(argv)
224
+ root_path = _normalize_root_path(args.root_path)
225
+
226
+ logging.basicConfig(
227
+ level=args.log_level.upper(),
228
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
229
+ )
230
+
231
+ socket_group: str | int | None = args.socket_group
232
+ if isinstance(socket_group, str) and socket_group.isdigit():
233
+ socket_group = int(socket_group)
234
+
235
+ async def run() -> None:
236
+ app = await build_app(args.listen, root_path,
237
+ socket_mode=args.socket_mode,
238
+ socket_group=socket_group)
239
+ runner = web.AppRunner(app)
240
+ await runner.setup()
241
+ site = web.TCPSite(runner, args.http_host, args.http_port)
242
+ await site.start()
243
+ log.info("UI on http://%s:%d/ (root-path=%r)",
244
+ args.http_host, args.http_port, root_path)
245
+
246
+ loop = asyncio.get_running_loop()
247
+ stop = loop.create_future()
248
+
249
+ def request_stop(sig: int) -> None:
250
+ if not stop.done():
251
+ log.info("received %s, shutting down", signal.Signals(sig).name)
252
+ stop.set_result(None)
253
+
254
+ for sig in (signal.SIGINT, signal.SIGTERM):
255
+ try:
256
+ loop.add_signal_handler(sig, request_stop, sig)
257
+ except NotImplementedError:
258
+ signal.signal(sig, lambda s, _f: request_stop(s))
259
+
260
+ try:
261
+ await stop
262
+ finally:
263
+ await runner.cleanup()
264
+
265
+ asyncio.run(run())
266
+ return 0
267
+
268
+
269
+ if __name__ == "__main__":
270
+ sys.exit(main())