apppack-stats 0.2.0__tar.gz
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,27 @@
|
|
|
1
|
+
# Byte-compiled / optimized
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
|
|
12
|
+
# Virtual environments
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
env/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
.python-version
|
|
19
|
+
uv.lock
|
|
20
|
+
|
|
21
|
+
# Editors
|
|
22
|
+
.vscode/
|
|
23
|
+
.idea/
|
|
24
|
+
*.swp
|
|
25
|
+
|
|
26
|
+
# OS
|
|
27
|
+
.DS_Store
|
|
@@ -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.
|
|
@@ -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,71 @@
|
|
|
1
|
+
# apppack-stats
|
|
2
|
+
|
|
3
|
+
Live response-time stats from streamed [AppPack](https://apppack.io) web logs.
|
|
4
|
+
|
|
5
|
+
`apppack-stats` shells out to `apppack logs --raw --follow` for the app you
|
|
6
|
+
name, parses the JSON request lines, and renders a live [Textual] data
|
|
7
|
+
table grouped by `(method, normalized path)`. Click any column header to
|
|
8
|
+
re-sort by it; click again to flip the direction.
|
|
9
|
+
|
|
10
|
+
[Textual]: https://textual.textualize.io/
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Slowest endpoints — 1842/1903 lines parsed, 47s elapsed
|
|
14
|
+
┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━┓
|
|
15
|
+
┃ Method ┃ Path ┃ Count ┃ Avg ms ┃ p95 ms ┃ Max ms ┃ Err ┃
|
|
16
|
+
┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━┩
|
|
17
|
+
│ GET │ /reports/<id>/export │ 7 │ 1842 │ 2210 │ 2480 │ │
|
|
18
|
+
│ POST │ /api/v1/orders │ 23 │ 412 │ 711 │ 980 │ 2 │
|
|
19
|
+
│ GET │ /dashboard │ 91 │ 188 │ 320 │ 640 │ │
|
|
20
|
+
└────────┴───────────────────────────────┴───────┴────────┴────────┴────────┴─────┘
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
No install required — run it straight from PyPI with
|
|
26
|
+
[`uv`](https://docs.astral.sh/uv/):
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
uvx apppack-stats <appname>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Press `q` (or `Ctrl+C`) to stop; a final summary table is printed once on exit.
|
|
33
|
+
|
|
34
|
+
### Keys
|
|
35
|
+
|
|
36
|
+
| Key | Action |
|
|
37
|
+
| ------------------- | -------------------------------------------- |
|
|
38
|
+
| Click column header | Sort by that column (click again to reverse) |
|
|
39
|
+
| `n` | Toggle URL normalization on/off |
|
|
40
|
+
| `q` | Quit |
|
|
41
|
+
|
|
42
|
+
The [AppPack CLI](https://docs.apppack.io/how-to/install-the-cli/) must be on
|
|
43
|
+
your `PATH` and authenticated against the account that owns the app.
|
|
44
|
+
|
|
45
|
+
## Options
|
|
46
|
+
|
|
47
|
+
| Flag | Default | Notes |
|
|
48
|
+
| ----------------- | ------- | --------------------------------------------------- |
|
|
49
|
+
| `--refresh SEC` | `1.0` | Seconds between redraws |
|
|
50
|
+
| `--start DUR` | `5m` | How far back to seed history (`30m`, `2h`, `1d`, …) |
|
|
51
|
+
| `--prefix STRING` | _none_ | AppPack log-group prefix filter — see note below |
|
|
52
|
+
| `--no-normalize` | off | Group by raw path instead of normalizing IDs |
|
|
53
|
+
|
|
54
|
+
The table fills the terminal and scrolls when there are more rows than fit.
|
|
55
|
+
|
|
56
|
+
### A note on `--prefix`
|
|
57
|
+
|
|
58
|
+
`apppack logs --prefix` filters on AppPack's underlying CloudWatch
|
|
59
|
+
log-group names, which don't always begin with the service name shown in
|
|
60
|
+
the rendered `(web/web/<task>)` label. Different AppPack apps use
|
|
61
|
+
different log-group naming conventions, so a fixed default like `web`
|
|
62
|
+
silently drops everything for some apps. By default `apppack-stats`
|
|
63
|
+
passes no prefix and lets the parser filter — only access-log JSON lines
|
|
64
|
+
(those with `method`, `path`, `status`, `response_time_us`) are counted;
|
|
65
|
+
everything else is skipped.
|
|
66
|
+
|
|
67
|
+
## URL normalization
|
|
68
|
+
|
|
69
|
+
By default, paths are normalized so that `/orders/12345` and `/orders/67890`
|
|
70
|
+
share a row as `/orders/<id>`. UUIDs become `/<uuid>`, long hex hashes
|
|
71
|
+
become `/<hash>`. Pass `--no-normalize` to keep raw paths.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apppack-stats"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Live response-time stats from streamed AppPack web logs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Martin Burchell", email = "martin@lincolnloop.com" }]
|
|
14
|
+
keywords = ["apppack", "logs", "stats", "monitoring", "observability"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Intended Audience :: System Administrators",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: System :: Monitoring",
|
|
26
|
+
"Topic :: System :: Logging",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"rich>=13",
|
|
30
|
+
"textual>=0.50",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
apppack-stats = "apppack_stats:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/lincolnloop/apppack-stats"
|
|
38
|
+
Issues = "https://github.com/lincolnloop/apppack-stats/issues"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/apppack_stats"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = [
|
|
45
|
+
"/src",
|
|
46
|
+
"/README.md",
|
|
47
|
+
"/LICENSE",
|
|
48
|
+
"/pyproject.toml",
|
|
49
|
+
]
|
|
@@ -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())
|