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.
- mod_wsgi/__init__.py +2 -0
- mod_wsgi/telemetry/__init__.py +1 -0
- mod_wsgi/telemetry/cli.py +55 -0
- mod_wsgi/telemetry/contention.py +229 -0
- mod_wsgi/telemetry/dump.py +102 -0
- mod_wsgi/telemetry/ingest.py +773 -0
- mod_wsgi/telemetry/server.py +270 -0
- mod_wsgi/telemetry/simulate.py +649 -0
- mod_wsgi/telemetry/static/index.html +8368 -0
- mod_wsgi/telemetry/tui.py +1536 -0
- mod_wsgi/telemetry/wire.py +480 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/METADATA +79 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/RECORD +16 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/WHEEL +4 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/entry_points.txt +2 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/licenses/LICENSE +202 -0
|
@@ -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())
|