wherewent 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.
- wherewent/__init__.py +3 -0
- wherewent/_shim/__init__.py +2 -0
- wherewent/_shim/sitecustomize.py +18 -0
- wherewent/callsite.py +99 -0
- wherewent/cli.py +90 -0
- wherewent/normalize.py +55 -0
- wherewent/recorder.py +383 -0
- wherewent/report.py +81 -0
- wherewent/rules.py +119 -0
- wherewent/stats.py +42 -0
- wherewent-0.1.0.dist-info/METADATA +204 -0
- wherewent-0.1.0.dist-info/RECORD +16 -0
- wherewent-0.1.0.dist-info/WHEEL +5 -0
- wherewent-0.1.0.dist-info/entry_points.txt +2 -0
- wherewent-0.1.0.dist-info/licenses/LICENSE +21 -0
- wherewent-0.1.0.dist-info/top_level.txt +1 -0
wherewent/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Auto-imported by Python at startup (this dir is on the child's PYTHONPATH).
|
|
2
|
+
|
|
3
|
+
Only acts when the CLI marked the environment. Any failure is swallowed so the
|
|
4
|
+
job runs exactly as it would without wherewent.
|
|
5
|
+
|
|
6
|
+
TODO: chaining the site's original sitecustomize is out of scope for this
|
|
7
|
+
prototype (if the environment already shipped one, it is shadowed here).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
if os.environ.get("WHEREWENT_ACTIVE") == "1":
|
|
13
|
+
try:
|
|
14
|
+
from wherewent.recorder import install_from_env
|
|
15
|
+
|
|
16
|
+
install_from_env()
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
wherewent/callsite.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Call-site resolution — find the user-code frame that issued a query.
|
|
2
|
+
|
|
3
|
+
This runs on the hot path (once per execution), so is_library_file is memoized
|
|
4
|
+
and the full stack capture is reserved for the first few samples of each group.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import sysconfig
|
|
10
|
+
|
|
11
|
+
# memoization cache for is_library_file (keyed by raw filename string)
|
|
12
|
+
_LIB_CACHE: "dict[str, bool]" = {}
|
|
13
|
+
|
|
14
|
+
# directory of the installed wherewent package itself (its frames are "library")
|
|
15
|
+
_WHEREWENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
16
|
+
|
|
17
|
+
# stdlib / install-tree prefixes computed once
|
|
18
|
+
_STDLIB_PREFIXES = set()
|
|
19
|
+
for _key in ("stdlib", "platstdlib", "purelib", "platlib"):
|
|
20
|
+
try:
|
|
21
|
+
_p = sysconfig.get_paths().get(_key)
|
|
22
|
+
if _p:
|
|
23
|
+
_STDLIB_PREFIXES.add(os.path.abspath(_p))
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
for _p in (getattr(sys, "prefix", None), getattr(sys, "base_prefix", None)):
|
|
27
|
+
if _p:
|
|
28
|
+
_STDLIB_PREFIXES.add(os.path.abspath(_p))
|
|
29
|
+
_STDLIB_PREFIXES = tuple(_STDLIB_PREFIXES)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _compute_is_library(filename: str) -> bool:
|
|
33
|
+
# frozen / built-in / <string> / <stdin> pseudo-files are never user code
|
|
34
|
+
if not filename or filename.startswith("<"):
|
|
35
|
+
return True
|
|
36
|
+
path = os.path.abspath(filename)
|
|
37
|
+
sep = os.sep
|
|
38
|
+
if "site-packages" in path or "dist-packages" in path:
|
|
39
|
+
return True
|
|
40
|
+
if sep + "sqlalchemy" + sep in path:
|
|
41
|
+
return True
|
|
42
|
+
if path.startswith(_WHEREWENT_DIR):
|
|
43
|
+
return True
|
|
44
|
+
for prefix in _STDLIB_PREFIXES:
|
|
45
|
+
if path.startswith(prefix):
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_library_file(filename: str) -> bool:
|
|
51
|
+
"""True if *filename* belongs to a library/stdlib/wherewent (memoized)."""
|
|
52
|
+
cached = _LIB_CACHE.get(filename)
|
|
53
|
+
if cached is not None:
|
|
54
|
+
return cached
|
|
55
|
+
result = _compute_is_library(filename)
|
|
56
|
+
_LIB_CACHE[filename] = result
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _short(filename: str) -> str:
|
|
61
|
+
"""A compact display name: path relative to cwd, else basename."""
|
|
62
|
+
try:
|
|
63
|
+
rel = os.path.relpath(filename, os.getcwd())
|
|
64
|
+
if not rel.startswith(".."):
|
|
65
|
+
return rel
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return os.path.basename(filename)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_call_site(skip: int = 1) -> "tuple[str, int, str] | None":
|
|
72
|
+
"""Walk up the stack and return the first user-code frame.
|
|
73
|
+
|
|
74
|
+
Returns (short_file, lineno, function) or None if only library frames exist.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
frame = sys._getframe(skip)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return None
|
|
80
|
+
while frame is not None:
|
|
81
|
+
filename = frame.f_code.co_filename
|
|
82
|
+
if not is_library_file(filename):
|
|
83
|
+
return (_short(filename), frame.f_lineno, frame.f_code.co_name)
|
|
84
|
+
frame = frame.f_back
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def capture_stack(limit: int = 30) -> "list[str]":
|
|
89
|
+
"""Return up to *limit* frames (user + library) as 'file:line in func'."""
|
|
90
|
+
frames = []
|
|
91
|
+
try:
|
|
92
|
+
frame = sys._getframe(1)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return frames
|
|
95
|
+
while frame is not None and len(frames) < limit:
|
|
96
|
+
code = frame.f_code
|
|
97
|
+
frames.append(f"{_short(code.co_filename)}:{frame.f_lineno} in {code.co_name}")
|
|
98
|
+
frame = frame.f_back
|
|
99
|
+
return frames
|
wherewent/cli.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Console entry point: `wherewent run [--save PATH] <command...>`.
|
|
2
|
+
|
|
3
|
+
Launches the target command in a subprocess whose PYTHONPATH is arranged so
|
|
4
|
+
Python auto-imports our sitecustomize shim, which installs the recorder before
|
|
5
|
+
the job's own code runs. Zero changes to the job itself.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
USAGE = (
|
|
14
|
+
"usage: wherewent run [--save PATH] <command...>\n"
|
|
15
|
+
" e.g. wherewent run python job.py\n"
|
|
16
|
+
" wherewent run --save out.json python -m mypkg\n"
|
|
17
|
+
" wherewent run job.py (bare script uses this interpreter)\n"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _usage():
|
|
22
|
+
sys.stderr.write(USAGE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _child_env(save):
|
|
26
|
+
import wherewent
|
|
27
|
+
import wherewent._shim as shim
|
|
28
|
+
|
|
29
|
+
# Directory that literally contains sitecustomize.py (so Python auto-imports it).
|
|
30
|
+
shim_dir = os.path.dirname(os.path.abspath(shim.__file__))
|
|
31
|
+
# Directory that contains the `wherewent` package, so the child can import it.
|
|
32
|
+
pkg_parent = os.path.dirname(os.path.dirname(os.path.abspath(wherewent.__file__)))
|
|
33
|
+
|
|
34
|
+
env = os.environ.copy()
|
|
35
|
+
existing = env.get("PYTHONPATH", "")
|
|
36
|
+
parts = [shim_dir, pkg_parent]
|
|
37
|
+
if existing:
|
|
38
|
+
parts.append(existing)
|
|
39
|
+
env["PYTHONPATH"] = os.pathsep.join(parts)
|
|
40
|
+
env["WHEREWENT_ACTIVE"] = "1"
|
|
41
|
+
env["WHEREWENT_SAVE"] = save or ""
|
|
42
|
+
return env
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv=None):
|
|
46
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
47
|
+
if not argv or argv[0] != "run":
|
|
48
|
+
_usage()
|
|
49
|
+
return 2
|
|
50
|
+
|
|
51
|
+
args = argv[1:]
|
|
52
|
+
save = None
|
|
53
|
+
i = 0
|
|
54
|
+
while i < len(args):
|
|
55
|
+
a = args[i]
|
|
56
|
+
if a == "--save":
|
|
57
|
+
if i + 1 >= len(args):
|
|
58
|
+
_usage()
|
|
59
|
+
return 2
|
|
60
|
+
save = args[i + 1]
|
|
61
|
+
i += 2
|
|
62
|
+
continue
|
|
63
|
+
break # first non-option token starts the command
|
|
64
|
+
command = args[i:]
|
|
65
|
+
|
|
66
|
+
if not command:
|
|
67
|
+
_usage()
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
# A bare `script.py` runs under this interpreter.
|
|
71
|
+
if command[0].endswith(".py"):
|
|
72
|
+
command = [sys.executable] + command
|
|
73
|
+
|
|
74
|
+
env = _child_env(save)
|
|
75
|
+
|
|
76
|
+
# While the child runs, the parent ignores Ctrl-C so the SIGINT goes to the
|
|
77
|
+
# child (which finalizes and prints the report); we exit with its code.
|
|
78
|
+
prev = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
79
|
+
try:
|
|
80
|
+
proc = subprocess.run(command, env=env)
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
sys.stderr.write(f"wherewent: command not found: {command[0]}\n")
|
|
83
|
+
return 127
|
|
84
|
+
finally:
|
|
85
|
+
signal.signal(signal.SIGINT, prev)
|
|
86
|
+
return proc.returncode
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
sys.exit(main())
|
wherewent/normalize.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""SQL normalization — turn a raw statement into a literal-free group signature.
|
|
2
|
+
|
|
3
|
+
We NEVER keep parameter values. The output is a shape string used only to group
|
|
4
|
+
structurally-identical queries together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
# --- pre-compiled patterns (applied in order) ---------------------------------
|
|
11
|
+
|
|
12
|
+
# 1. comments: -- to end of line, and /* ... */ blocks (dotall for multiline)
|
|
13
|
+
_LINE_COMMENT = re.compile(r"--[^\n]*")
|
|
14
|
+
_BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
|
|
15
|
+
|
|
16
|
+
# 2. single-quoted string literals, honoring the '' escape sequence
|
|
17
|
+
_STRING = re.compile(r"'(?:[^']|'')*'")
|
|
18
|
+
|
|
19
|
+
# 3. bind placeholders: %s, %(name)s, :name, $1-style (done before numbers so
|
|
20
|
+
# that $1 is treated as a placeholder rather than the number 1)
|
|
21
|
+
_PLACEHOLDER = re.compile(r"%\(\w+\)s|%s|\$\d+|(?<![:\w]):\w+")
|
|
22
|
+
|
|
23
|
+
# 4. numeric literals: standalone numbers only, never the trailing digits of an
|
|
24
|
+
# identifier like col1 (lookbehind rejects a preceding word char or dot)
|
|
25
|
+
_NUMBER = re.compile(r"(?<![\w.])\d+(?:\.\d+)?(?:[eE][+-]?\d+)?")
|
|
26
|
+
|
|
27
|
+
# 5. whitespace runs
|
|
28
|
+
_WS = re.compile(r"\s+")
|
|
29
|
+
|
|
30
|
+
# 6. IN (?, ?, ?) -> IN (?)
|
|
31
|
+
_IN_LIST = re.compile(r"(?i)\bIN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)")
|
|
32
|
+
|
|
33
|
+
# 7. multi-row VALUES (?, ?), (?, ?), ... -> VALUES (?, ?) (keep first tuple)
|
|
34
|
+
_MULTI_VALUES = re.compile(r"(?i)(\bVALUES\s*)(\([^()]*\))(?:\s*,\s*\([^()]*\))+")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_sql(sql: str) -> str:
|
|
38
|
+
"""Return a literal-free, whitespace-collapsed signature for *sql*."""
|
|
39
|
+
if not sql:
|
|
40
|
+
return ""
|
|
41
|
+
s = sql
|
|
42
|
+
s = _LINE_COMMENT.sub(" ", s)
|
|
43
|
+
s = _BLOCK_COMMENT.sub(" ", s)
|
|
44
|
+
s = _STRING.sub("?", s)
|
|
45
|
+
s = _PLACEHOLDER.sub("?", s)
|
|
46
|
+
s = _NUMBER.sub("?", s)
|
|
47
|
+
s = _WS.sub(" ", s).strip()
|
|
48
|
+
s = _IN_LIST.sub("IN (?)", s)
|
|
49
|
+
s = _MULTI_VALUES.sub(r"\1\2", s)
|
|
50
|
+
return s
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def group_key(normalized: str) -> str:
|
|
54
|
+
"""Stable 12-hex-char group id derived from a normalized statement."""
|
|
55
|
+
return hashlib.sha1(normalized.encode()).hexdigest()[:12]
|
wherewent/recorder.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""The recorder: class-level SQLAlchemy event hooks + finalize/report.
|
|
2
|
+
|
|
3
|
+
Design invariants (see build contract):
|
|
4
|
+
* Never crash the host job: every hook body is wrapped in try/except.
|
|
5
|
+
* Never store parameter VALUES — only counts, durations, and normalized SQL.
|
|
6
|
+
* Cheap hot path: memoized library detection, bounded normalization cache,
|
|
7
|
+
full stack capture only for the first 5 samples of each group.
|
|
8
|
+
* finalize() prints exactly once, to stderr, via atexit AND a signal handler.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import statistics
|
|
16
|
+
import sys
|
|
17
|
+
from time import perf_counter
|
|
18
|
+
|
|
19
|
+
from . import report, rules
|
|
20
|
+
from .callsite import capture_stack, resolve_call_site
|
|
21
|
+
from .normalize import group_key, normalize_sql
|
|
22
|
+
from .stats import GroupSnapshot, RunSnapshot
|
|
23
|
+
|
|
24
|
+
_NORM_CACHE_MAX = 4096
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Recorder:
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.installed = False
|
|
30
|
+
self.finalized = False
|
|
31
|
+
self.save_path = None
|
|
32
|
+
|
|
33
|
+
self.start_perf = None
|
|
34
|
+
self._start_times = None
|
|
35
|
+
|
|
36
|
+
# per-group accumulators keyed by group_key
|
|
37
|
+
self.groups = {}
|
|
38
|
+
# normalization cache keyed by raw SQL string (bounded)
|
|
39
|
+
self._norm_cache = {}
|
|
40
|
+
# per-execution start times keyed by id(context)
|
|
41
|
+
self._exec_start = {}
|
|
42
|
+
# per-connection transaction generation (kept for --save completeness)
|
|
43
|
+
self._txn_gen = {}
|
|
44
|
+
|
|
45
|
+
self.total_queries = 0
|
|
46
|
+
self.total_commits = 0
|
|
47
|
+
self.total_rollbacks = 0
|
|
48
|
+
self.total_rows = 0
|
|
49
|
+
self.db_time = 0.0
|
|
50
|
+
self.overhead_time = 0.0
|
|
51
|
+
|
|
52
|
+
self.commit_time = 0.0
|
|
53
|
+
self.commit_measurable = False # only True once a dialect is wrapped
|
|
54
|
+
|
|
55
|
+
self.sqlalchemy_active = False
|
|
56
|
+
self._prev_handlers = {}
|
|
57
|
+
|
|
58
|
+
# -- installation ----------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def install(self, save_path=None):
|
|
61
|
+
if self.installed:
|
|
62
|
+
return
|
|
63
|
+
self.installed = True
|
|
64
|
+
self.save_path = save_path or None
|
|
65
|
+
self.start_perf = perf_counter()
|
|
66
|
+
try:
|
|
67
|
+
self._start_times = os.times()
|
|
68
|
+
except Exception:
|
|
69
|
+
self._start_times = None
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
import sqlalchemy # noqa: F401
|
|
73
|
+
from sqlalchemy import event
|
|
74
|
+
from sqlalchemy.engine import Engine
|
|
75
|
+
|
|
76
|
+
# class-level: every engine in the process is captured, zero config
|
|
77
|
+
event.listen(Engine, "before_cursor_execute", self._before, named=True)
|
|
78
|
+
event.listen(Engine, "after_cursor_execute", self._after, named=True)
|
|
79
|
+
event.listen(Engine, "begin", self._on_begin)
|
|
80
|
+
event.listen(Engine, "commit", self._on_commit)
|
|
81
|
+
event.listen(Engine, "rollback", self._on_rollback)
|
|
82
|
+
event.listen(Engine, "engine_connect", self._on_connect)
|
|
83
|
+
self.sqlalchemy_active = True
|
|
84
|
+
except Exception:
|
|
85
|
+
self.sqlalchemy_active = False
|
|
86
|
+
|
|
87
|
+
atexit.register(self.finalize)
|
|
88
|
+
self._install_signal_handlers()
|
|
89
|
+
|
|
90
|
+
def _install_signal_handlers(self):
|
|
91
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
92
|
+
try:
|
|
93
|
+
self._prev_handlers[sig] = signal.getsignal(sig)
|
|
94
|
+
signal.signal(sig, self._handle_signal)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def _handle_signal(self, signum, frame):
|
|
99
|
+
# Ctrl-C mid-job MUST still produce the report.
|
|
100
|
+
self.finalize()
|
|
101
|
+
try:
|
|
102
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
sys.exit(130 if signum == signal.SIGINT else 143)
|
|
106
|
+
|
|
107
|
+
# -- statement hooks -------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _before(self, **kw):
|
|
110
|
+
t0 = perf_counter()
|
|
111
|
+
try:
|
|
112
|
+
ctx = kw.get("context")
|
|
113
|
+
if ctx is not None:
|
|
114
|
+
self._exec_start[id(ctx)] = perf_counter()
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
finally:
|
|
118
|
+
self.overhead_time += perf_counter() - t0
|
|
119
|
+
|
|
120
|
+
def _after(self, **kw):
|
|
121
|
+
t0 = perf_counter()
|
|
122
|
+
try:
|
|
123
|
+
ctx = kw.get("context")
|
|
124
|
+
statement = kw.get("statement") or ""
|
|
125
|
+
cursor = kw.get("cursor")
|
|
126
|
+
executemany = bool(kw.get("executemany"))
|
|
127
|
+
|
|
128
|
+
start = self._exec_start.pop(id(ctx), None) if ctx is not None else None
|
|
129
|
+
duration = (perf_counter() - start) if start is not None else 0.0
|
|
130
|
+
|
|
131
|
+
self.sqlalchemy_active = True
|
|
132
|
+
self.total_queries += 1
|
|
133
|
+
self.db_time += duration
|
|
134
|
+
|
|
135
|
+
key, normalized = self._normalize_cached(statement)
|
|
136
|
+
g = self.groups.get(key)
|
|
137
|
+
if g is None:
|
|
138
|
+
g = {
|
|
139
|
+
"normalized_sql": normalized,
|
|
140
|
+
"calls": 0,
|
|
141
|
+
"durations": [],
|
|
142
|
+
"total_time": 0.0,
|
|
143
|
+
"rows": 0,
|
|
144
|
+
"executemany_calls": 0,
|
|
145
|
+
"call_sites": {}, # (file,line,func) -> count
|
|
146
|
+
"sample_stacks": [],
|
|
147
|
+
}
|
|
148
|
+
self.groups[key] = g
|
|
149
|
+
|
|
150
|
+
g["calls"] += 1
|
|
151
|
+
g["durations"].append(duration)
|
|
152
|
+
g["total_time"] += duration
|
|
153
|
+
if executemany:
|
|
154
|
+
g["executemany_calls"] += 1
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
rc = cursor.rowcount if cursor is not None else None
|
|
158
|
+
if rc is not None and rc >= 0:
|
|
159
|
+
g["rows"] += rc
|
|
160
|
+
self.total_rows += rc
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
site = resolve_call_site(skip=1)
|
|
165
|
+
if site is not None:
|
|
166
|
+
g["call_sites"][site] = g["call_sites"].get(site, 0) + 1
|
|
167
|
+
|
|
168
|
+
# full stack capture only for the first 5 executions per group
|
|
169
|
+
if g["calls"] <= 5:
|
|
170
|
+
g["sample_stacks"].append(capture_stack())
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
finally:
|
|
174
|
+
self.overhead_time += perf_counter() - t0
|
|
175
|
+
|
|
176
|
+
# -- transaction hooks -----------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _on_begin(self, conn):
|
|
179
|
+
t0 = perf_counter()
|
|
180
|
+
try:
|
|
181
|
+
self._txn_gen[id(conn)] = self._txn_gen.get(id(conn), 0) + 1
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
finally:
|
|
185
|
+
self.overhead_time += perf_counter() - t0
|
|
186
|
+
|
|
187
|
+
def _on_commit(self, conn):
|
|
188
|
+
t0 = perf_counter()
|
|
189
|
+
try:
|
|
190
|
+
self.total_commits += 1
|
|
191
|
+
self._txn_gen[id(conn)] = self._txn_gen.get(id(conn), 0) + 1
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
finally:
|
|
195
|
+
self.overhead_time += perf_counter() - t0
|
|
196
|
+
|
|
197
|
+
def _on_rollback(self, conn):
|
|
198
|
+
t0 = perf_counter()
|
|
199
|
+
try:
|
|
200
|
+
self.total_rollbacks += 1
|
|
201
|
+
self._txn_gen[id(conn)] = self._txn_gen.get(id(conn), 0) + 1
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
finally:
|
|
205
|
+
self.overhead_time += perf_counter() - t0
|
|
206
|
+
|
|
207
|
+
def _on_connect(self, conn, *args):
|
|
208
|
+
# engine_connect fires with the Connection positionally (2.0: (conn);
|
|
209
|
+
# older: (conn, branch)). There is no after-commit event, so wrap the
|
|
210
|
+
# dialect's do_commit / do_rollback with a perf_counter timer here.
|
|
211
|
+
# Idempotent per dialect.
|
|
212
|
+
t0 = perf_counter()
|
|
213
|
+
try:
|
|
214
|
+
dialect = getattr(conn, "dialect", None)
|
|
215
|
+
if dialect is not None and not getattr(dialect, "_wherewent_wrapped", False):
|
|
216
|
+
self._wrap_dialect(dialect)
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
finally:
|
|
220
|
+
self.overhead_time += perf_counter() - t0
|
|
221
|
+
|
|
222
|
+
def _wrap_dialect(self, dialect):
|
|
223
|
+
rec = self
|
|
224
|
+
orig_commit = dialect.do_commit
|
|
225
|
+
orig_rollback = dialect.do_rollback
|
|
226
|
+
|
|
227
|
+
def timed_commit(dbapi_connection, _orig=orig_commit):
|
|
228
|
+
t = perf_counter()
|
|
229
|
+
try:
|
|
230
|
+
return _orig(dbapi_connection)
|
|
231
|
+
finally:
|
|
232
|
+
try:
|
|
233
|
+
rec.commit_time += perf_counter() - t
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
def timed_rollback(dbapi_connection, _orig=orig_rollback):
|
|
238
|
+
t = perf_counter()
|
|
239
|
+
try:
|
|
240
|
+
return _orig(dbapi_connection)
|
|
241
|
+
finally:
|
|
242
|
+
try:
|
|
243
|
+
rec.commit_time += perf_counter() - t
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
dialect.do_commit = timed_commit
|
|
248
|
+
dialect.do_rollback = timed_rollback
|
|
249
|
+
dialect._wherewent_wrapped = True
|
|
250
|
+
self.commit_measurable = True
|
|
251
|
+
|
|
252
|
+
# -- normalization cache ---------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def _normalize_cached(self, statement):
|
|
255
|
+
cached = self._norm_cache.get(statement)
|
|
256
|
+
if cached is not None:
|
|
257
|
+
return cached
|
|
258
|
+
normalized = normalize_sql(statement)
|
|
259
|
+
key = group_key(normalized)
|
|
260
|
+
if len(self._norm_cache) >= _NORM_CACHE_MAX:
|
|
261
|
+
self._norm_cache.clear() # bounded: on overflow just drop everything
|
|
262
|
+
self._norm_cache[statement] = (key, normalized)
|
|
263
|
+
return key, normalized
|
|
264
|
+
|
|
265
|
+
# -- snapshot / finalize ---------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def snapshot(self) -> RunSnapshot:
|
|
268
|
+
wall = (perf_counter() - self.start_perf) if self.start_perf else 0.0
|
|
269
|
+
|
|
270
|
+
cpu = None
|
|
271
|
+
if self._start_times is not None:
|
|
272
|
+
try:
|
|
273
|
+
now = os.times()
|
|
274
|
+
cpu = (now.user - self._start_times.user) + (
|
|
275
|
+
now.system - self._start_times.system
|
|
276
|
+
)
|
|
277
|
+
except Exception:
|
|
278
|
+
cpu = None
|
|
279
|
+
|
|
280
|
+
groups = []
|
|
281
|
+
for key, g in self.groups.items():
|
|
282
|
+
durations = g["durations"]
|
|
283
|
+
median = statistics.median(durations) if durations else 0.0
|
|
284
|
+
site = None
|
|
285
|
+
if g["call_sites"]:
|
|
286
|
+
site = max(g["call_sites"].items(), key=lambda kv: kv[1])[0]
|
|
287
|
+
groups.append(
|
|
288
|
+
GroupSnapshot(
|
|
289
|
+
key=key,
|
|
290
|
+
normalized_sql=g["normalized_sql"],
|
|
291
|
+
calls=g["calls"],
|
|
292
|
+
total_time=g["total_time"],
|
|
293
|
+
median=median,
|
|
294
|
+
rows=g["rows"],
|
|
295
|
+
executemany_calls=g["executemany_calls"],
|
|
296
|
+
call_site=site,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
commit_time = self.commit_time if self.commit_measurable else None
|
|
301
|
+
|
|
302
|
+
return RunSnapshot(
|
|
303
|
+
wall_time=wall,
|
|
304
|
+
cpu_time=cpu,
|
|
305
|
+
total_queries=self.total_queries,
|
|
306
|
+
total_commits=self.total_commits,
|
|
307
|
+
total_rollbacks=self.total_rollbacks,
|
|
308
|
+
commit_time=commit_time,
|
|
309
|
+
total_rows=self.total_rows,
|
|
310
|
+
db_time=self.db_time,
|
|
311
|
+
overhead_time=self.overhead_time,
|
|
312
|
+
sqlalchemy_active=self.sqlalchemy_active,
|
|
313
|
+
groups=groups,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def finalize(self):
|
|
317
|
+
if self.finalized:
|
|
318
|
+
return
|
|
319
|
+
self.finalized = True
|
|
320
|
+
try:
|
|
321
|
+
run = self.snapshot()
|
|
322
|
+
findings = rules.evaluate(run)
|
|
323
|
+
text = report.render(run, findings)
|
|
324
|
+
sys.stderr.write(text + "\n")
|
|
325
|
+
sys.stderr.flush()
|
|
326
|
+
if self.save_path:
|
|
327
|
+
self._write_json(run, findings)
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
def _write_json(self, run: RunSnapshot, findings):
|
|
332
|
+
# groups in the snapshot are sorted for the report; for JSON we walk the
|
|
333
|
+
# raw accumulators so we can attach the captured sample stacks too.
|
|
334
|
+
group_by_key = {g.key: g for g in run.groups}
|
|
335
|
+
groups_json = []
|
|
336
|
+
for key, gs in group_by_key.items():
|
|
337
|
+
raw = self.groups.get(key, {})
|
|
338
|
+
groups_json.append(
|
|
339
|
+
{
|
|
340
|
+
"key": gs.key,
|
|
341
|
+
"normalized_sql": gs.normalized_sql,
|
|
342
|
+
"calls": gs.calls,
|
|
343
|
+
"total_time": gs.total_time,
|
|
344
|
+
"median": gs.median,
|
|
345
|
+
"rows": gs.rows,
|
|
346
|
+
"executemany_calls": gs.executemany_calls,
|
|
347
|
+
"call_site": list(gs.call_site) if gs.call_site else None,
|
|
348
|
+
"sample_stacks": raw.get("sample_stacks", []),
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
payload = {
|
|
353
|
+
"wall_time": run.wall_time,
|
|
354
|
+
"cpu_time": run.cpu_time,
|
|
355
|
+
"total_queries": run.total_queries,
|
|
356
|
+
"total_commits": run.total_commits,
|
|
357
|
+
"total_rollbacks": run.total_rollbacks,
|
|
358
|
+
"commit_time": run.commit_time,
|
|
359
|
+
"total_rows": run.total_rows,
|
|
360
|
+
"db_time": run.db_time,
|
|
361
|
+
"overhead_time": run.overhead_time,
|
|
362
|
+
"sqlalchemy_active": run.sqlalchemy_active,
|
|
363
|
+
"groups": groups_json,
|
|
364
|
+
"findings": [
|
|
365
|
+
{"rule": f.rule, "title": f.title, "detail": f.detail, "seconds": f.seconds}
|
|
366
|
+
for f in findings
|
|
367
|
+
],
|
|
368
|
+
}
|
|
369
|
+
try:
|
|
370
|
+
with open(self.save_path, "w") as fh:
|
|
371
|
+
json.dump(payload, fh, indent=2)
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# module-level singleton
|
|
377
|
+
recorder = Recorder()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def install_from_env() -> None:
|
|
381
|
+
"""Entry point called by the shim: install using WHEREWENT_SAVE from env."""
|
|
382
|
+
save = os.environ.get("WHEREWENT_SAVE") or None
|
|
383
|
+
recorder.install(save)
|
wherewent/report.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Plain-text report renderer (~100 columns, stderr-friendly).
|
|
2
|
+
|
|
3
|
+
Golden rule: never print a guessed number. Anything unmeasurable prints as "—".
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .stats import Finding, RunSnapshot
|
|
7
|
+
|
|
8
|
+
_WIDTH = 100
|
|
9
|
+
_SQL_COL = 48
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _pct(part: float, whole: float) -> str:
|
|
13
|
+
return f"{part / whole * 100:.1f}%" if whole > 0 else "—"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render(run: RunSnapshot, findings: "list[Finding]") -> str:
|
|
17
|
+
wall = run.wall_time
|
|
18
|
+
lines = []
|
|
19
|
+
lines.append("=" * _WIDTH)
|
|
20
|
+
lines.append("wherewent — SQL flight recorder")
|
|
21
|
+
lines.append("-" * _WIDTH)
|
|
22
|
+
|
|
23
|
+
if run.cpu_time is not None and wall > 0:
|
|
24
|
+
cpu_str = f"{run.cpu_time:.2f}s ({run.cpu_time / wall * 100:.0f}% CPU busy)"
|
|
25
|
+
else:
|
|
26
|
+
cpu_str = "—"
|
|
27
|
+
lines.append(
|
|
28
|
+
f"wall: {wall:.2f}s cpu: {cpu_str} "
|
|
29
|
+
f"queries: {run.total_queries:,} commits: {run.total_commits:,} "
|
|
30
|
+
f"rollbacks: {run.total_rollbacks:,}"
|
|
31
|
+
)
|
|
32
|
+
lines.append(
|
|
33
|
+
f"in-DB time: {run.db_time:.2f}s ({_pct(run.db_time, wall)} of wall; "
|
|
34
|
+
f"app-observed: includes network+driver+server)"
|
|
35
|
+
)
|
|
36
|
+
commit_str = f"{run.commit_time:.2f}s" if run.commit_time is not None else "—"
|
|
37
|
+
lines.append(f"commit time: {commit_str} total rows: {run.total_rows:,}")
|
|
38
|
+
lines.append(
|
|
39
|
+
f"recording added ~{run.overhead_time:.2f}s (~{_pct(run.overhead_time, wall)} of wall)"
|
|
40
|
+
)
|
|
41
|
+
lines.append("=" * _WIDTH)
|
|
42
|
+
|
|
43
|
+
if not run.sqlalchemy_active:
|
|
44
|
+
lines.append("SQLAlchemy was not imported by this process — nothing recorded.")
|
|
45
|
+
lines.append("=" * _WIDTH)
|
|
46
|
+
return "\n".join(lines)
|
|
47
|
+
|
|
48
|
+
# --- query group table (top 15 by total app time) -------------------------
|
|
49
|
+
groups = sorted(run.groups, key=lambda g: g.total_time, reverse=True)[:15]
|
|
50
|
+
lines.append(
|
|
51
|
+
f"{'QUERY GROUP':<{_SQL_COL}} {'CALLS':>8} {'TOTAL':>9} {'MEDIAN':>10} CALL SITE"
|
|
52
|
+
)
|
|
53
|
+
lines.append("-" * _WIDTH)
|
|
54
|
+
if not groups:
|
|
55
|
+
lines.append("(no queries recorded)")
|
|
56
|
+
for g in groups:
|
|
57
|
+
sql = g.normalized_sql
|
|
58
|
+
if len(sql) > _SQL_COL:
|
|
59
|
+
sql = sql[: _SQL_COL - 3] + "..."
|
|
60
|
+
if g.call_site:
|
|
61
|
+
site = f"{g.call_site[0]}:{g.call_site[1]} in {g.call_site[2]}"
|
|
62
|
+
else:
|
|
63
|
+
site = "—"
|
|
64
|
+
tot = f"{g.total_time:.2f}s"
|
|
65
|
+
med = f"{g.median * 1000:.2f}ms"
|
|
66
|
+
lines.append(f"{sql:<{_SQL_COL}} {g.calls:>8,} {tot:>9} {med:>10} {site}")
|
|
67
|
+
|
|
68
|
+
# --- findings -------------------------------------------------------------
|
|
69
|
+
lines.append("=" * _WIDTH)
|
|
70
|
+
lines.append("FINDINGS")
|
|
71
|
+
lines.append("-" * _WIDTH)
|
|
72
|
+
if not findings:
|
|
73
|
+
lines.append("No findings fired (thresholds: see README).")
|
|
74
|
+
else:
|
|
75
|
+
for i, f in enumerate(findings, 1):
|
|
76
|
+
lines.append(f"{i}. [{f.rule}] {f.title}")
|
|
77
|
+
for dl in f.detail.split("\n"):
|
|
78
|
+
lines.append(f" {dl}")
|
|
79
|
+
lines.append(f" ~= {f.seconds:.1f}s attributable")
|
|
80
|
+
lines.append("=" * _WIDTH)
|
|
81
|
+
return "\n".join(lines)
|
wherewent/rules.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Deterministic heuristics that turn a RunSnapshot into human findings.
|
|
2
|
+
|
|
3
|
+
Every threshold and merge rule here is part of the build contract. Numbers must
|
|
4
|
+
stay honest: a rule only fires when its inputs are actually measurable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .stats import Finding, RunSnapshot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _site_str(group) -> str:
|
|
11
|
+
if group.call_site:
|
|
12
|
+
f, ln, _fn = group.call_site
|
|
13
|
+
return f"{f}:{ln}"
|
|
14
|
+
return "unknown"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _within(a: float, b: float, frac: float) -> bool:
|
|
18
|
+
"""True if a and b are within *frac* of each other (relative)."""
|
|
19
|
+
hi = max(a, b)
|
|
20
|
+
if hi <= 0:
|
|
21
|
+
return False
|
|
22
|
+
return (min(a, b) / hi) >= (1.0 - frac)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def evaluate(run: RunSnapshot) -> "list[Finding]":
|
|
26
|
+
wall = run.wall_time
|
|
27
|
+
if wall <= 0:
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
cpu_busy = (run.cpu_time / wall) if (run.cpu_time is not None and wall > 0) else None
|
|
31
|
+
|
|
32
|
+
r1 = r1_group = None
|
|
33
|
+
r2 = None
|
|
34
|
+
r3 = None
|
|
35
|
+
|
|
36
|
+
# --- R1: chatty query group (worst by total_time) -------------------------
|
|
37
|
+
candidates = [
|
|
38
|
+
g for g in run.groups
|
|
39
|
+
if g.calls > 1000 and g.total_time > 0.10 * wall and g.median < 0.005
|
|
40
|
+
]
|
|
41
|
+
if candidates:
|
|
42
|
+
g = max(candidates, key=lambda x: x.total_time)
|
|
43
|
+
r1_group = g
|
|
44
|
+
detail = (
|
|
45
|
+
f"{g.calls:,} calls x {g.median * 1000:.2f}ms median "
|
|
46
|
+
f"~= {g.total_time:.1f}s = {g.total_time / wall * 100:.0f}% of {wall:.1f}s wall, "
|
|
47
|
+
f"at {_site_str(g)}. Batch it (executemany / IN-list / JOIN)."
|
|
48
|
+
)
|
|
49
|
+
r1 = Finding("R1", "Chatty query group", detail, g.total_time)
|
|
50
|
+
|
|
51
|
+
# --- R2: commit-per-row ---------------------------------------------------
|
|
52
|
+
if (
|
|
53
|
+
run.total_commits > 100
|
|
54
|
+
and run.commit_time is not None
|
|
55
|
+
and (run.total_rows / run.total_commits) < 10
|
|
56
|
+
and run.commit_time > 0.05 * wall
|
|
57
|
+
):
|
|
58
|
+
avg = run.total_rows / run.total_commits
|
|
59
|
+
detail = (
|
|
60
|
+
f"{run.total_commits:,} commits for {run.total_rows:,} rows "
|
|
61
|
+
f"({avg:.1f} rows/commit), {run.commit_time:.1f}s in commit "
|
|
62
|
+
f"= {run.commit_time / wall * 100:.0f}% of wall. "
|
|
63
|
+
f"Batch to 1,000+ rows per transaction."
|
|
64
|
+
)
|
|
65
|
+
r2 = Finding("R2", "Commit-per-row", detail, run.commit_time)
|
|
66
|
+
|
|
67
|
+
# --- R3: DB-wait dominates ------------------------------------------------
|
|
68
|
+
if run.db_time > 0.60 * wall and cpu_busy is not None and cpu_busy < 0.30:
|
|
69
|
+
detail = (
|
|
70
|
+
f"DB {run.db_time:.1f}s of {wall:.1f}s wall "
|
|
71
|
+
f"({run.db_time / wall * 100:.0f}%), CPU busy only {cpu_busy * 100:.0f}% "
|
|
72
|
+
f"-> round-trip bound, not compute bound."
|
|
73
|
+
)
|
|
74
|
+
r3 = Finding("R3", "DB-wait dominates", detail, run.db_time)
|
|
75
|
+
|
|
76
|
+
# --- merge / fold ---------------------------------------------------------
|
|
77
|
+
findings: "list[Finding]" = []
|
|
78
|
+
consumed = set()
|
|
79
|
+
|
|
80
|
+
# R1 + R2 same-loop merge (calls ~= commits within 25%)
|
|
81
|
+
if r1 is not None and r2 is not None and _within(r1_group.calls, run.total_commits, 0.25):
|
|
82
|
+
consumed.update({"r1", "r2"})
|
|
83
|
+
rule = "R1+R2"
|
|
84
|
+
detail = r1.detail + "\n" + r2.detail
|
|
85
|
+
seconds = r1.seconds + r2.seconds
|
|
86
|
+
if r3 is not None:
|
|
87
|
+
consumed.add("r3")
|
|
88
|
+
rule = "R1+R2+R3"
|
|
89
|
+
detail = detail + "\n" + r3.detail # R3 overlaps R1's query time; don't add seconds
|
|
90
|
+
findings.append(Finding(rule, "commit-per-row loop", detail, seconds))
|
|
91
|
+
|
|
92
|
+
# R3 + exactly one other, where that other explains >= 80% of the DB wait
|
|
93
|
+
if "r3" not in consumed and r3 is not None:
|
|
94
|
+
other = other_name = None
|
|
95
|
+
if r1 is not None and r2 is None:
|
|
96
|
+
other, other_name = r1, "r1"
|
|
97
|
+
elif r2 is not None and r1 is None:
|
|
98
|
+
other, other_name = r2, "r2"
|
|
99
|
+
if other is not None and other.seconds >= 0.8 * run.db_time:
|
|
100
|
+
consumed.update({"r3", other_name})
|
|
101
|
+
if other_name == "r1":
|
|
102
|
+
# R1's query time is a subset of db_time: attribute db_time, no double-count
|
|
103
|
+
seconds = max(other.seconds, r3.seconds)
|
|
104
|
+
rule = "R1+R3"
|
|
105
|
+
else:
|
|
106
|
+
# commit time is disjoint from cursor-execute db_time: additive
|
|
107
|
+
seconds = other.seconds + r3.seconds
|
|
108
|
+
rule = "R2+R3"
|
|
109
|
+
findings.append(Finding(rule, other.title, other.detail + "\n" + r3.detail, seconds))
|
|
110
|
+
|
|
111
|
+
# emit any survivors that were not folded into a merge
|
|
112
|
+
for name, f in (("r1", r1), ("r2", r2), ("r3", r3)):
|
|
113
|
+
if f is not None and name not in consumed:
|
|
114
|
+
findings.append(f)
|
|
115
|
+
|
|
116
|
+
# suppress trivia, sort, cap at 3
|
|
117
|
+
findings = [f for f in findings if f.seconds >= 0.05 * wall]
|
|
118
|
+
findings.sort(key=lambda f: f.seconds, reverse=True)
|
|
119
|
+
return findings[:3]
|
wherewent/stats.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared dataclasses — the load-bearing contract that rules/report/tests depend on.
|
|
2
|
+
|
|
3
|
+
Field names and types here are frozen: do not rename or reorder without updating
|
|
4
|
+
every consumer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class GroupSnapshot:
|
|
12
|
+
key: str # 12-hex-char sha1 prefix of normalized_sql
|
|
13
|
+
normalized_sql: str
|
|
14
|
+
calls: int
|
|
15
|
+
total_time: float # seconds, app-observed (includes driver+network+server)
|
|
16
|
+
median: float # seconds; 0.0 if no samples
|
|
17
|
+
rows: int # summed cursor.rowcount where >= 0, else 0 contribution
|
|
18
|
+
executemany_calls: int
|
|
19
|
+
call_site: "tuple[str, int, str] | None" # (file, line, function); None if unresolved
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RunSnapshot:
|
|
24
|
+
wall_time: float # seconds
|
|
25
|
+
cpu_time: "float | None" # process user+sys CPU seconds (os.times); None if unmeasurable
|
|
26
|
+
total_queries: int
|
|
27
|
+
total_commits: int
|
|
28
|
+
total_rollbacks: int
|
|
29
|
+
commit_time: "float | None" # seconds inside DBAPI commit; None if not measurable
|
|
30
|
+
total_rows: int
|
|
31
|
+
db_time: float # sum of all cursor-execute durations
|
|
32
|
+
overhead_time: float # recorder's own measured hook time, seconds
|
|
33
|
+
sqlalchemy_active: bool # were hooks installed and did SQLAlchemy get used
|
|
34
|
+
groups: "list[GroupSnapshot]" = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Finding:
|
|
39
|
+
rule: str # "R1" | "R2" | "R3" | "R1+R2" | "R1+R2+R3" | "R1+R3" | "R2+R3"
|
|
40
|
+
title: str # one line
|
|
41
|
+
detail: str # multi-line: MUST show the arithmetic
|
|
42
|
+
seconds: float # estimated seconds attributable
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wherewent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-config SQL flight recorder that answers 'why did this Python batch job take so long?'
|
|
5
|
+
Author-email: Habiba Faisal <habibafaisal8@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/habibafaisal/wherewent
|
|
8
|
+
Project-URL: Repository, https://github.com/habibafaisal/wherewent
|
|
9
|
+
Project-URL: Issues, https://github.com/habibafaisal/wherewent/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/habibafaisal/wherewent/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: profiling,performance,sqlalchemy,postgresql,sql,observability,batch,n+1,orm
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Database
|
|
20
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Classifier: Environment :: Console
|
|
23
|
+
Classifier: Operating System :: OS Independent
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: psutil
|
|
28
|
+
Requires-Dist: psutil; extra == "psutil"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest; extra == "dev"
|
|
31
|
+
Requires-Dist: sqlalchemy>=2.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
<div align="center">
|
|
35
|
+
|
|
36
|
+
# wherewent
|
|
37
|
+
|
|
38
|
+
### Where did the time go? Find out in one command.
|
|
39
|
+
|
|
40
|
+
**A zero-config recorder that answers "why did this Python batch job take so long?"**
|
|
41
|
+
|
|
42
|
+
[](https://pypi.org/project/wherewent/)
|
|
43
|
+
[](https://pypi.org/project/wherewent/)
|
|
44
|
+
[](LICENSE)
|
|
45
|
+
[](https://github.com/habibafaisal/wherewent/actions/workflows/ci.yml)
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
wherewent run python your_job.py
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## The 0.4ms query that costs you 5 minutes
|
|
56
|
+
|
|
57
|
+
A query can be individually fast — `0.4ms` — and still sink your job, because it's
|
|
58
|
+
called **500,000 times from a single line of code**. Your app burns 300 seconds on
|
|
59
|
+
round-trips while Postgres itself only worked for 80. Every profiler you've tried shows
|
|
60
|
+
you *"time spent in psycopg"* and stops there.
|
|
61
|
+
|
|
62
|
+
**wherewent shows you the calling pattern.** It groups queries by shape, counts how
|
|
63
|
+
often each shape ran, sums the wall time, and points at the exact `file:line` in *your*
|
|
64
|
+
code that fired it — then tells you, in plain English with the arithmetic shown, what to
|
|
65
|
+
do about it.
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
====================================================================================================
|
|
69
|
+
wherewent — SQL flight recorder
|
|
70
|
+
----------------------------------------------------------------------------------------------------
|
|
71
|
+
wall: 26.15s cpu: 25.41s (97% CPU busy) queries: 20,004 commits: 20,001 rollbacks: 1
|
|
72
|
+
in-DB time: 5.46s (20.9% of wall; app-observed: includes network+driver+server)
|
|
73
|
+
commit time: 9.06s total rows: 20,000
|
|
74
|
+
recording added ~1.81s (~6.9% of wall)
|
|
75
|
+
====================================================================================================
|
|
76
|
+
QUERY GROUP CALLS TOTAL MEDIAN CALL SITE
|
|
77
|
+
----------------------------------------------------------------------------------------------------
|
|
78
|
+
INSERT INTO events (name, value) VALUES (?, ?) 20,000 5.46s 0.24ms demo/naive_job.py:65 in main
|
|
79
|
+
SELECT count(*) AS count_1 FROM events 1 0.00s 0.16ms demo/naive_job.py:71 in main
|
|
80
|
+
====================================================================================================
|
|
81
|
+
FINDINGS
|
|
82
|
+
----------------------------------------------------------------------------------------------------
|
|
83
|
+
1. [R1+R2] commit-per-row loop
|
|
84
|
+
20,000 calls x 0.24ms median ~= 5.5s = 21% of 26.1s wall, at demo/naive_job.py:65. Batch it.
|
|
85
|
+
20,001 commits for 20,000 rows (1.0 rows/commit), 9.1s in commit = 35% of wall. Batch to 1,000+ rows/txn.
|
|
86
|
+
~= 14.5s attributable
|
|
87
|
+
====================================================================================================
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Why it's different
|
|
91
|
+
|
|
92
|
+
| | Sampling profilers | APM / tracing | **wherewent** |
|
|
93
|
+
|---|:---:|:---:|:---:|
|
|
94
|
+
| Zero code changes | ✅ | ❌ | ✅ |
|
|
95
|
+
| Groups queries by shape | ❌ | ⚠️ | ✅ |
|
|
96
|
+
| Blames *your* call site | ⚠️ | ⚠️ | ✅ |
|
|
97
|
+
| Tells you the fix | ❌ | ❌ | ✅ |
|
|
98
|
+
| Runs anywhere, no server | ✅ | ❌ | ✅ |
|
|
99
|
+
| Works on a Ctrl-C'd partial run | ❌ | ⚠️ | ✅ |
|
|
100
|
+
|
|
101
|
+
## Install
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install wherewent
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That's it — the recorder is **pure standard library**. You only need SQLAlchemy
|
|
108
|
+
because *your job* already uses it.
|
|
109
|
+
|
|
110
|
+
## Use it
|
|
111
|
+
|
|
112
|
+
Wrap any command. Your script runs **completely unmodified** — no imports, no decorators,
|
|
113
|
+
no config:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
wherewent run python your_job.py --some arg
|
|
117
|
+
wherewent run python -m your_package
|
|
118
|
+
wherewent run --save run.json python your_job.py # also dump machine-readable JSON
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- The report prints to **stderr** at exit; your job's own stdout/stderr pass through untouched.
|
|
122
|
+
- **Ctrl-C still produces a report.** Sampling the first 5 minutes of a 14-hour job is the
|
|
123
|
+
main use case — partial data is the point.
|
|
124
|
+
- **It can never crash or corrupt your job.** Every hook body is wrapped so the recorder
|
|
125
|
+
fails silent rather than taking your run down with it.
|
|
126
|
+
- **It never records your data.** Only query *shapes* and *counts* are kept — literal
|
|
127
|
+
values and bind parameters are stripped before anything is stored.
|
|
128
|
+
|
|
129
|
+
### Try the built-in demo
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
git clone https://github.com/habibafaisal/wherewent && cd wherewent
|
|
133
|
+
pip install -e ".[dev]"
|
|
134
|
+
wherewent run python demo/naive_job.py # watch the R1+R2 finding fire
|
|
135
|
+
python demo/benchmark.py # naive vs fixed, with the overhead gate
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## How it works
|
|
139
|
+
|
|
140
|
+
1. **Injects itself** into the target process via a `PYTHONPATH` sitecustomize shim — no
|
|
141
|
+
changes to your code, no wrapper imports.
|
|
142
|
+
2. **Listens at the class level** — `event.listen(sqlalchemy.engine.Engine, ...)` — so
|
|
143
|
+
*every* engine your app creates is captured automatically, config-free.
|
|
144
|
+
3. **Normalizes each statement** into a query *group*: literals, bind params, `IN`-lists
|
|
145
|
+
and multi-row `VALUES` collapse, so a million distinct inserts become one honest row.
|
|
146
|
+
4. **Resolves the call site** by walking the stack past library frames to the first line
|
|
147
|
+
of *your* code — cheaply: cached by filename, full stacks only for the first 5 samples
|
|
148
|
+
per group, so the hot path stays cheap enough to hit its overhead budget.
|
|
149
|
+
5. **Fires deterministic findings** from three rules, each showing its arithmetic.
|
|
150
|
+
|
|
151
|
+
### The findings engine
|
|
152
|
+
|
|
153
|
+
| Rule | Fires when | Tells you |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| **R1 — chatty group** | > 1,000 calls, > 10% of wall, median < 5ms | A fast query is called too many times — batch it (`executemany` / `IN`-list / `JOIN`). |
|
|
156
|
+
| **R2 — commit-per-row** | > 100 commits, < 10 rows/commit, > 5% of wall in commit | You're committing per row — batch to 1,000+ rows per transaction. |
|
|
157
|
+
| **R3 — DB-wait bound** | in-DB time > 60% of wall, CPU busy < 30% | The job is round-trip bound, not compute bound. |
|
|
158
|
+
|
|
159
|
+
Findings that share a root cause **merge** (e.g. `R1+R2`), everything under 5% of wall is
|
|
160
|
+
suppressed, and at most the top 3 are shown — ranked by seconds attributable.
|
|
161
|
+
|
|
162
|
+
**Every number is honest.** Query times are labelled *app-observed* (they include network,
|
|
163
|
+
driver, and server time — not just Postgres). Anything that can't be measured prints `—`,
|
|
164
|
+
never a guess. wherewent even times *its own hooks* and reports the overhead it added.
|
|
165
|
+
|
|
166
|
+
## Roadmap — help wanted 🙌
|
|
167
|
+
|
|
168
|
+
wherewent is built to grow **beyond SQLAlchemy**. Seven of its eight modules —
|
|
169
|
+
normalization, call-site resolution, the stats model, the rules engine, the report, the
|
|
170
|
+
CLI, and the injection shim — are already **framework-agnostic**. They operate on a plain
|
|
171
|
+
`RunSnapshot` of query events. Only `recorder.py`, which binds SQLAlchemy's event system,
|
|
172
|
+
is framework-specific.
|
|
173
|
+
|
|
174
|
+
**That means a new backend is a well-contained contribution:** capture query
|
|
175
|
+
start/end/rowcount/txn events from another driver, feed the same `RunSnapshot`, and the
|
|
176
|
+
entire findings-and-report pipeline works for free. Good first backends:
|
|
177
|
+
|
|
178
|
+
- [ ] **Raw `psycopg` / `psycopg2`** — cursor subclass or connection factory hook
|
|
179
|
+
- [ ] **`asyncpg` / async SQLAlchemy** — the async execution path
|
|
180
|
+
- [ ] **Django ORM** — via `connection.execute_wrapper`
|
|
181
|
+
- [ ] **Generic DB-API 2.0** — a monkeypatch-free `Cursor` proxy
|
|
182
|
+
- [ ] New findings rules (N+1 `SELECT` detection, lock-wait, seq-scan heuristics)
|
|
183
|
+
|
|
184
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the backend contract and the **< 15% overhead
|
|
185
|
+
gate** that every capture path must pass.
|
|
186
|
+
|
|
187
|
+
## Limitations (today)
|
|
188
|
+
|
|
189
|
+
- SQLAlchemy **2.x, synchronous** only (1.4 may work; async does not yet).
|
|
190
|
+
- Single process — no multiprocessing or async fan-out.
|
|
191
|
+
- Query times are app-observed (network + driver + server), by design.
|
|
192
|
+
- Commit timing is obtained by wrapping the dialect's commit; if that wrap fails it prints `—`.
|
|
193
|
+
|
|
194
|
+
These are the honest edges of a validation prototype, not permanent walls — see the roadmap.
|
|
195
|
+
|
|
196
|
+
## Contributing
|
|
197
|
+
|
|
198
|
+
Contributions are very welcome — new backends, new rules, docs, bug reports. Start with
|
|
199
|
+
[`CONTRIBUTING.md`](CONTRIBUTING.md), open an issue to discuss anything substantial, and
|
|
200
|
+
run `pytest && python demo/benchmark.py` before you push.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
[MIT](LICENSE) © 2026 Habiba Faisal
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
wherewent/__init__.py,sha256=d62PR2Qhvc7BZCSDvffjx6dpoO9soQtS6gVaiSwiIJc,108
|
|
2
|
+
wherewent/callsite.py,sha256=BfLjAqKAkgipiLeNcXc6WQolXS-EwEdhoLQyINhhqjg,3172
|
|
3
|
+
wherewent/cli.py,sha256=xlm2mr3IZU8B8mKwxErO4KgTrxp-pVoUF31yF_h7or8,2577
|
|
4
|
+
wherewent/normalize.py,sha256=1E0zerQefcmuwXHq7qlpvqngh1sorExavhIzhUUV5zU,1950
|
|
5
|
+
wherewent/recorder.py,sha256=upykLxIjcutJMNm6RXxqkWTmdJttiz_uUqK1RuMbjww,13219
|
|
6
|
+
wherewent/report.py,sha256=1cwXhLXFme3e8VzYNeTXR8BHMyAG1BsSR7mDJZHAVB8,2965
|
|
7
|
+
wherewent/rules.py,sha256=EvP-WTv2SlLxIgzUKf8A9kV8bjzkcKn03V_7pXQlDYc,4714
|
|
8
|
+
wherewent/stats.py,sha256=EuQ7EV5JsFWKdPIzhvJoVXXX3imet7PUUJoS2vZmV0I,1647
|
|
9
|
+
wherewent/_shim/__init__.py,sha256=UQaL9OROUZV4txD12NURZdAUl513ugLvXlWHx54XBk4,147
|
|
10
|
+
wherewent/_shim/sitecustomize.py,sha256=rjww2F6hx8ILrZsV_Q-cyg905pNfrmVwjFyBEwQHv-c,545
|
|
11
|
+
wherewent-0.1.0.dist-info/licenses/LICENSE,sha256=Y4N2_d6kVd8vbQz5NTh7fdq9YsRC5u6gzcbyia_ZFgw,1070
|
|
12
|
+
wherewent-0.1.0.dist-info/METADATA,sha256=7BbXzH065KiNT0812lZX9p3G1gJzPwDpik35XTKNXmw,9672
|
|
13
|
+
wherewent-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
14
|
+
wherewent-0.1.0.dist-info/entry_points.txt,sha256=rFsqA2cAQZ1cy-8EtkKXHppn4fJ61-llJvomxf67lHw,49
|
|
15
|
+
wherewent-0.1.0.dist-info/top_level.txt,sha256=LBzV2Qrv0W5rgMSKVYIJJFt9MNeDbBCsPPNN9kYfLVU,10
|
|
16
|
+
wherewent-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Habiba Faisal
|
|
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 @@
|
|
|
1
|
+
wherewent
|