apppack-stats 0.2.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.
@@ -0,0 +1,353 @@
1
+ """Live response-time stats from streamed AppPack web logs.
2
+
3
+ Spawns ``apppack logs --raw --follow`` for the requested app and aggregates
4
+ incoming requests per ``(method, normalized path)`` into a sortable Textual
5
+ data table. Click any column header to sort by it; click again to reverse.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import re
13
+ import shutil
14
+ import statistics
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ from collections import defaultdict
19
+ from dataclasses import dataclass, field
20
+ from time import monotonic
21
+ from typing import IO
22
+
23
+ from rich.console import Console
24
+ from rich.table import Table
25
+ from textual.app import App, ComposeResult
26
+ from textual.widgets import DataTable, Footer, Header
27
+
28
+ __version__ = "0.2.0"
29
+
30
+ # Order matters: UUIDs and hex hashes before bare ints.
31
+ _UUID_RE = re.compile(
32
+ r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|$)", re.I
33
+ )
34
+ _HEX_RE = re.compile(r"/[0-9a-f]{16,}(?=/|$)", re.I)
35
+ _INT_RE = re.compile(r"/\d+(?=/|$)")
36
+
37
+
38
+ def normalize_path(path: str) -> str:
39
+ path = _UUID_RE.sub("/<uuid>", path)
40
+ path = _HEX_RE.sub("/<hash>", path)
41
+ path = _INT_RE.sub("/<id>", path)
42
+ return path
43
+
44
+
45
+ @dataclass
46
+ class Bucket:
47
+ count: int = 0
48
+ errors: int = 0
49
+ times_us: list[int] = field(default_factory=list)
50
+
51
+ def add(self, time_us: int, status: int) -> None:
52
+ self.count += 1
53
+ self.times_us.append(time_us)
54
+ if status >= 400:
55
+ self.errors += 1
56
+
57
+ @property
58
+ def avg_ms(self) -> float:
59
+ return statistics.fmean(self.times_us) / 1000
60
+
61
+ @property
62
+ def p95_ms(self) -> float:
63
+ if len(self.times_us) < 2:
64
+ return self.times_us[0] / 1000
65
+ return statistics.quantiles(self.times_us, n=20)[18] / 1000
66
+
67
+ @property
68
+ def max_ms(self) -> float:
69
+ return max(self.times_us) / 1000
70
+
71
+
72
+ class Stats:
73
+ def __init__(self, *, normalize: bool) -> None:
74
+ self.normalize = normalize
75
+ self.buckets: dict[tuple[str, str], Bucket] = defaultdict(Bucket)
76
+ self.total_lines = 0
77
+ self.parsed_lines = 0
78
+ self.started = monotonic()
79
+ self.lock = threading.Lock()
80
+
81
+ def ingest(self, line: str) -> None:
82
+ self.total_lines += 1
83
+ line = line.strip()
84
+ if not line or line[0] != "{":
85
+ return
86
+ try:
87
+ payload = json.loads(line)
88
+ method = payload["method"]
89
+ path = payload["path"]
90
+ time_us = int(payload["response_time_us"])
91
+ status = int(payload["status"])
92
+ except (ValueError, KeyError, TypeError):
93
+ return
94
+
95
+ if self.normalize:
96
+ path = normalize_path(path)
97
+
98
+ with self.lock:
99
+ self.buckets[(method, path)].add(time_us, status)
100
+ self.parsed_lines += 1
101
+
102
+ def summary_table(self, *, sort_col: str, reverse: bool) -> Table:
103
+ with self.lock:
104
+ items = list(self.buckets.items())
105
+ elapsed = monotonic() - self.started
106
+ total = self.total_lines
107
+ parsed = self.parsed_lines
108
+ items.sort(key=_SORT_KEYS[sort_col], reverse=reverse)
109
+
110
+ table = Table(
111
+ title=f"apppack-stats — {parsed}/{total} lines, {elapsed:.0f}s elapsed",
112
+ caption=f"sort: {sort_col} {'↓' if reverse else '↑'}",
113
+ )
114
+ table.add_column("Method", style="cyan", no_wrap=True)
115
+ table.add_column("Path", style="white", overflow="fold")
116
+ table.add_column("Count", justify="right", style="dim")
117
+ table.add_column("Avg ms", justify="right", style="bold yellow")
118
+ table.add_column("p95 ms", justify="right")
119
+ table.add_column("Max ms", justify="right")
120
+ table.add_column("Err", justify="right", style="red")
121
+ for (method, path), bucket in items:
122
+ table.add_row(
123
+ method,
124
+ path,
125
+ str(bucket.count),
126
+ f"{bucket.avg_ms:.0f}",
127
+ f"{bucket.p95_ms:.0f}",
128
+ f"{bucket.max_ms:.0f}",
129
+ str(bucket.errors) if bucket.errors else "",
130
+ )
131
+ return table
132
+
133
+
134
+ # Column key -> sort key over (key, bucket) tuples.
135
+ _SORT_KEYS: dict[str, "callable"] = {
136
+ "method": lambda kv: kv[0][0],
137
+ "path": lambda kv: kv[0][1],
138
+ "count": lambda kv: kv[1].count,
139
+ "avg": lambda kv: kv[1].avg_ms,
140
+ "p95": lambda kv: kv[1].p95_ms,
141
+ "max": lambda kv: kv[1].max_ms,
142
+ "err": lambda kv: kv[1].errors,
143
+ }
144
+
145
+ _NUMERIC_COLS = {"count", "avg", "p95", "max", "err"}
146
+
147
+
148
+ def _reader_thread(stats: Stats, stream: IO[str], stop: threading.Event) -> None:
149
+ for line in stream:
150
+ if stop.is_set():
151
+ break
152
+ stats.ingest(line)
153
+ stop.set()
154
+
155
+
156
+ class StatsApp(App):
157
+ """Textual UI for live response-time stats."""
158
+
159
+ CSS = """
160
+ DataTable {
161
+ height: 1fr;
162
+ }
163
+ """
164
+
165
+ BINDINGS = [
166
+ ("q", "quit", "Quit"),
167
+ ("n", "toggle_normalize", "Toggle path normalization"),
168
+ ]
169
+
170
+ def __init__(
171
+ self,
172
+ stats: Stats,
173
+ *,
174
+ refresh: float,
175
+ proc: subprocess.Popen,
176
+ ) -> None:
177
+ super().__init__()
178
+ self.stats = stats
179
+ self.refresh_sec = refresh
180
+ self.proc = proc
181
+ self.sort_col = "avg"
182
+ self.sort_reverse = True
183
+
184
+ def compose(self) -> ComposeResult:
185
+ yield Header()
186
+ yield DataTable(zebra_stripes=True, cursor_type="row")
187
+ yield Footer()
188
+
189
+ def on_mount(self) -> None:
190
+ table = self.query_one(DataTable)
191
+ table.add_column("Method", key="method", width=8)
192
+ table.add_column("Path", key="path")
193
+ table.add_column("Count", key="count", width=8)
194
+ table.add_column("Avg ms", key="avg", width=8)
195
+ table.add_column("p95 ms", key="p95", width=8)
196
+ table.add_column("Max ms", key="max", width=8)
197
+ table.add_column("Err", key="err", width=6)
198
+ self.title = "apppack-stats"
199
+ self._update_subtitle()
200
+ self.set_interval(self.refresh_sec, self._tick)
201
+ self._tick()
202
+
203
+ def on_data_table_header_selected(
204
+ self, event: DataTable.HeaderSelected
205
+ ) -> None:
206
+ col_key = str(event.column_key.value) if event.column_key else ""
207
+ if col_key not in _SORT_KEYS:
208
+ return
209
+ if col_key == self.sort_col:
210
+ self.sort_reverse = not self.sort_reverse
211
+ else:
212
+ self.sort_col = col_key
213
+ self.sort_reverse = col_key in _NUMERIC_COLS
214
+ self._update_subtitle()
215
+ self._tick()
216
+
217
+ def action_toggle_normalize(self) -> None:
218
+ with self.stats.lock:
219
+ self.stats.normalize = not self.stats.normalize
220
+ self._update_subtitle()
221
+
222
+ def _update_subtitle(self) -> None:
223
+ arrow = "↓" if self.sort_reverse else "↑"
224
+ norm = "" if self.stats.normalize else " (raw paths)"
225
+ self.sub_title = f"sort: {self.sort_col} {arrow}{norm}"
226
+
227
+ def _tick(self) -> None:
228
+ if self.proc.poll() is not None:
229
+ self.exit()
230
+ return
231
+ with self.stats.lock:
232
+ items = list(self.stats.buckets.items())
233
+ elapsed = monotonic() - self.stats.started
234
+ total = self.stats.total_lines
235
+ parsed = self.stats.parsed_lines
236
+ items.sort(key=_SORT_KEYS[self.sort_col], reverse=self.sort_reverse)
237
+
238
+ table = self.query_one(DataTable)
239
+ table.clear()
240
+ for (method, path), bucket in items:
241
+ table.add_row(
242
+ method,
243
+ path,
244
+ str(bucket.count),
245
+ f"{bucket.avg_ms:.0f}",
246
+ f"{bucket.p95_ms:.0f}",
247
+ f"{bucket.max_ms:.0f}",
248
+ str(bucket.errors) if bucket.errors else "",
249
+ )
250
+ self.title = f"apppack-stats — {parsed}/{total} lines, {elapsed:.0f}s"
251
+
252
+
253
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
254
+ parser = argparse.ArgumentParser(
255
+ prog="apppack-stats",
256
+ description="Live response-time stats from an AppPack web service.",
257
+ )
258
+ parser.add_argument("app", help="AppPack app name")
259
+ parser.add_argument(
260
+ "--refresh",
261
+ type=float,
262
+ default=1.0,
263
+ help="seconds between redraws (default: 1.0)",
264
+ )
265
+ parser.add_argument(
266
+ "--start",
267
+ default="5m",
268
+ help="how far back to seed history, e.g. 30m, 2h, 1d (default: 5m)",
269
+ )
270
+ parser.add_argument(
271
+ "--prefix",
272
+ default=None,
273
+ help=(
274
+ "log group prefix filter passed to `apppack logs --prefix`. "
275
+ "By default no filter is applied — non-access-log lines are "
276
+ "skipped by the parser anyway."
277
+ ),
278
+ )
279
+ parser.add_argument(
280
+ "--no-normalize",
281
+ dest="normalize",
282
+ action="store_false",
283
+ help="disable URL normalization (group by raw path)",
284
+ )
285
+ parser.add_argument(
286
+ "--version", action="version", version=f"%(prog)s {__version__}"
287
+ )
288
+ return parser.parse_args(argv)
289
+
290
+
291
+ def main(argv: list[str] | None = None) -> int:
292
+ args = _parse_args(argv)
293
+
294
+ if shutil.which("apppack") is None:
295
+ sys.stderr.write(
296
+ "error: 'apppack' CLI not found on PATH.\n"
297
+ "Install it from https://docs.apppack.io/how-to/install-the-cli/\n"
298
+ )
299
+ return 2
300
+
301
+ cmd = [
302
+ "apppack", "logs",
303
+ "-a", args.app,
304
+ "--follow",
305
+ "--raw",
306
+ "--start", args.start,
307
+ ]
308
+ if args.prefix:
309
+ cmd += ["--prefix", args.prefix]
310
+
311
+ proc = subprocess.Popen(
312
+ cmd,
313
+ stdout=subprocess.PIPE,
314
+ stderr=subprocess.PIPE,
315
+ text=True,
316
+ bufsize=1,
317
+ )
318
+ assert proc.stdout is not None
319
+
320
+ stats = Stats(normalize=args.normalize)
321
+ stop = threading.Event()
322
+ reader = threading.Thread(
323
+ target=_reader_thread, args=(stats, proc.stdout, stop), daemon=True
324
+ )
325
+ reader.start()
326
+
327
+ app = StatsApp(stats, refresh=args.refresh, proc=proc)
328
+ try:
329
+ app.run()
330
+ finally:
331
+ stop.set()
332
+ proc.terminate()
333
+ try:
334
+ proc.wait(timeout=2)
335
+ except subprocess.TimeoutExpired:
336
+ proc.kill()
337
+ proc.wait()
338
+
339
+ Console().print(
340
+ stats.summary_table(sort_col=app.sort_col, reverse=app.sort_reverse)
341
+ )
342
+
343
+ rc = proc.returncode or 0
344
+ if rc not in (0, -15):
345
+ stderr = proc.stderr.read() if proc.stderr else ""
346
+ if stderr:
347
+ sys.stderr.write(stderr)
348
+ return rc
349
+ return 0
350
+
351
+
352
+ if __name__ == "__main__":
353
+ raise SystemExit(main())
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: apppack-stats
3
+ Version: 0.2.0
4
+ Summary: Live response-time stats from streamed AppPack web logs.
5
+ Project-URL: Homepage, https://github.com/lincolnloop/apppack-stats
6
+ Project-URL: Issues, https://github.com/lincolnloop/apppack-stats/issues
7
+ Author-email: Martin Burchell <martin@lincolnloop.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: apppack,logs,monitoring,observability,stats
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: System :: Logging
21
+ Classifier: Topic :: System :: Monitoring
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: rich>=13
24
+ Requires-Dist: textual>=0.50
25
+ Description-Content-Type: text/markdown
26
+
27
+ # apppack-stats
28
+
29
+ Live response-time stats from streamed [AppPack](https://apppack.io) web logs.
30
+
31
+ `apppack-stats` shells out to `apppack logs --raw --follow` for the app you
32
+ name, parses the JSON request lines, and renders a live [Textual] data
33
+ table grouped by `(method, normalized path)`. Click any column header to
34
+ re-sort by it; click again to flip the direction.
35
+
36
+ [Textual]: https://textual.textualize.io/
37
+
38
+ ```
39
+ Slowest endpoints — 1842/1903 lines parsed, 47s elapsed
40
+ ┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━┓
41
+ ┃ Method ┃ Path ┃ Count ┃ Avg ms ┃ p95 ms ┃ Max ms ┃ Err ┃
42
+ ┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━┩
43
+ │ GET │ /reports/<id>/export │ 7 │ 1842 │ 2210 │ 2480 │ │
44
+ │ POST │ /api/v1/orders │ 23 │ 412 │ 711 │ 980 │ 2 │
45
+ │ GET │ /dashboard │ 91 │ 188 │ 320 │ 640 │ │
46
+ └────────┴───────────────────────────────┴───────┴────────┴────────┴────────┴─────┘
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ No install required — run it straight from PyPI with
52
+ [`uv`](https://docs.astral.sh/uv/):
53
+
54
+ ```sh
55
+ uvx apppack-stats <appname>
56
+ ```
57
+
58
+ Press `q` (or `Ctrl+C`) to stop; a final summary table is printed once on exit.
59
+
60
+ ### Keys
61
+
62
+ | Key | Action |
63
+ | ------------------- | -------------------------------------------- |
64
+ | Click column header | Sort by that column (click again to reverse) |
65
+ | `n` | Toggle URL normalization on/off |
66
+ | `q` | Quit |
67
+
68
+ The [AppPack CLI](https://docs.apppack.io/how-to/install-the-cli/) must be on
69
+ your `PATH` and authenticated against the account that owns the app.
70
+
71
+ ## Options
72
+
73
+ | Flag | Default | Notes |
74
+ | ----------------- | ------- | --------------------------------------------------- |
75
+ | `--refresh SEC` | `1.0` | Seconds between redraws |
76
+ | `--start DUR` | `5m` | How far back to seed history (`30m`, `2h`, `1d`, …) |
77
+ | `--prefix STRING` | _none_ | AppPack log-group prefix filter — see note below |
78
+ | `--no-normalize` | off | Group by raw path instead of normalizing IDs |
79
+
80
+ The table fills the terminal and scrolls when there are more rows than fit.
81
+
82
+ ### A note on `--prefix`
83
+
84
+ `apppack logs --prefix` filters on AppPack's underlying CloudWatch
85
+ log-group names, which don't always begin with the service name shown in
86
+ the rendered `(web/web/<task>)` label. Different AppPack apps use
87
+ different log-group naming conventions, so a fixed default like `web`
88
+ silently drops everything for some apps. By default `apppack-stats`
89
+ passes no prefix and lets the parser filter — only access-log JSON lines
90
+ (those with `method`, `path`, `status`, `response_time_us`) are counted;
91
+ everything else is skipped.
92
+
93
+ ## URL normalization
94
+
95
+ By default, paths are normalized so that `/orders/12345` and `/orders/67890`
96
+ share a row as `/orders/<id>`. UUIDs become `/<uuid>`, long hex hashes
97
+ become `/<hash>`. Pass `--no-normalize` to keep raw paths.
@@ -0,0 +1,6 @@
1
+ apppack_stats/__init__.py,sha256=FVgJjBENP8yOrZzyy6UFnMlaZe-EjvdhVO74n8EsZ-c,10553
2
+ apppack_stats-0.2.0.dist-info/METADATA,sha256=qZeYnGk7Z1tK_zABInRK-wtpt-c4HE3vIuw5xNYqDnI,4793
3
+ apppack_stats-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
4
+ apppack_stats-0.2.0.dist-info/entry_points.txt,sha256=LtFnzeDp-toLg0IfRkzhzrmcCUdkKeRRz438ZER_xX0,53
5
+ apppack_stats-0.2.0.dist-info/licenses/LICENSE,sha256=fdXvbawgqy1iiR7LbljkBE9fA87BMbDQ46IYGBRhZpU,1094
6
+ apppack_stats-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ apppack-stats = apppack_stats:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Mahner <martin@elephant.house>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.