heapspy-mcp 0.1.1__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.
- heapspy_mcp/__init__.py +3 -0
- heapspy_mcp/__main__.py +167 -0
- heapspy_mcp/allocations.py +347 -0
- heapspy_mcp/capture.py +242 -0
- heapspy_mcp/cli.py +518 -0
- heapspy_mcp/config.py +380 -0
- heapspy_mcp/cpu.py +1081 -0
- heapspy_mcp/processes.py +152 -0
- heapspy_mcp/py.typed +0 -0
- heapspy_mcp/pyspy.py +628 -0
- heapspy_mcp/server.py +545 -0
- heapspy_mcp/snapshots.py +82 -0
- heapspy_mcp/source_map.py +700 -0
- heapspy_mcp/summarize.py +429 -0
- heapspy_mcp/system/__init__.py +1296 -0
- heapspy_mcp/system/cgroups.py +667 -0
- heapspy_mcp/system/colocation.py +654 -0
- heapspy_mcp/system/diagnose.py +1042 -0
- heapspy_mcp/system/groups.py +553 -0
- heapspy_mcp/system/listing.py +64 -0
- heapspy_mcp/system/perf.py +424 -0
- heapspy_mcp/system/probe.py +354 -0
- heapspy_mcp/system/procfs.py +629 -0
- heapspy_mcp/system/redact.py +105 -0
- heapspy_mcp/system/sampling.py +267 -0
- heapspy_mcp/system/search.py +1116 -0
- heapspy_mcp/system/stalls.py +731 -0
- heapspy_mcp/system/taskstats.py +166 -0
- heapspy_mcp/system/views.py +635 -0
- heapspy_mcp/system/wait_categories.py +379 -0
- heapspy_mcp/system/windows.py +425 -0
- heapspy_mcp-0.1.1.dist-info/METADATA +1544 -0
- heapspy_mcp-0.1.1.dist-info/RECORD +35 -0
- heapspy_mcp-0.1.1.dist-info/WHEEL +4 -0
- heapspy_mcp-0.1.1.dist-info/entry_points.txt +2 -0
heapspy_mcp/__init__.py
ADDED
heapspy_mcp/__main__.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Command-line entry point: ``heapspy-mcp``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from . import config as cpu_config # CPU profiling (py-spy)
|
|
11
|
+
from .config import (
|
|
12
|
+
DEFAULT_CAPTURE_TIMEOUT,
|
|
13
|
+
DEFAULT_TIMEOUT,
|
|
14
|
+
ENV_ALLOW,
|
|
15
|
+
ENV_BIN,
|
|
16
|
+
ENV_CAPTURE_TIMEOUT,
|
|
17
|
+
ENV_MAX_RSS,
|
|
18
|
+
ENV_MAX_STR,
|
|
19
|
+
ENV_SNAPSHOT_DIR,
|
|
20
|
+
ENV_TIMEOUT,
|
|
21
|
+
Config,
|
|
22
|
+
ConfigError,
|
|
23
|
+
add_diagnostics_arguments,
|
|
24
|
+
default_snapshot_dir,
|
|
25
|
+
diagnostics_from_args,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
30
|
+
p = argparse.ArgumentParser(
|
|
31
|
+
prog="heapspy-mcp",
|
|
32
|
+
description="MCP server exposing heapspy heap snapshots and analysis to coding agents.",
|
|
33
|
+
)
|
|
34
|
+
p.add_argument("--version", action="version", version=f"heapspy-mcp {__version__}")
|
|
35
|
+
p.add_argument(
|
|
36
|
+
"--transport",
|
|
37
|
+
choices=["stdio", "streamable-http"],
|
|
38
|
+
default="stdio",
|
|
39
|
+
help="MCP transport (default: stdio)",
|
|
40
|
+
)
|
|
41
|
+
p.add_argument("--host", default="127.0.0.1", help="streamable-http bind address")
|
|
42
|
+
p.add_argument("--port", type=int, default=8000, help="streamable-http port")
|
|
43
|
+
p.add_argument(
|
|
44
|
+
"--heapspy-bin",
|
|
45
|
+
metavar="PATH",
|
|
46
|
+
help=f"heapspy executable (else ${ENV_BIN}, else 'heapspy' on PATH)",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--allow",
|
|
50
|
+
action="append",
|
|
51
|
+
metavar="PATTERN",
|
|
52
|
+
help=(
|
|
53
|
+
"allow live capture of processes matching PATTERN: a glob on the process name, "
|
|
54
|
+
"executable name or command line, or pid:N. Repeatable; comma-separated lists "
|
|
55
|
+
f"accepted. Merged with ${ENV_ALLOW}. Default: none (live capture disabled)."
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
p.add_argument(
|
|
59
|
+
"--max-str",
|
|
60
|
+
type=int,
|
|
61
|
+
metavar="N",
|
|
62
|
+
help=(
|
|
63
|
+
"default per-string cap, in characters, for capture_contents=true when the "
|
|
64
|
+
f"tool call gives no max_str (also ${ENV_MAX_STR}; default 1024)"
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
p.add_argument(
|
|
68
|
+
"--max-rss",
|
|
69
|
+
metavar="SIZE",
|
|
70
|
+
help=f"refuse live capture above this RSS, e.g. 8G (also ${ENV_MAX_RSS}; default 8G)",
|
|
71
|
+
)
|
|
72
|
+
p.add_argument(
|
|
73
|
+
"--snapshot-dir",
|
|
74
|
+
metavar="DIR",
|
|
75
|
+
help=f"where snapshots are written (also ${ENV_SNAPSHOT_DIR}; "
|
|
76
|
+
f"default {default_snapshot_dir()})",
|
|
77
|
+
)
|
|
78
|
+
p.add_argument(
|
|
79
|
+
"--timeout",
|
|
80
|
+
type=float,
|
|
81
|
+
metavar="SECONDS",
|
|
82
|
+
help=f"timeout for analysis commands (also ${ENV_TIMEOUT}; default {DEFAULT_TIMEOUT:g})",
|
|
83
|
+
)
|
|
84
|
+
p.add_argument(
|
|
85
|
+
"--capture-timeout",
|
|
86
|
+
type=float,
|
|
87
|
+
metavar="SECONDS",
|
|
88
|
+
help=f"timeout for heapspy dump (also ${ENV_CAPTURE_TIMEOUT}; "
|
|
89
|
+
f"default {DEFAULT_CAPTURE_TIMEOUT:g})",
|
|
90
|
+
)
|
|
91
|
+
add_diagnostics_arguments(p)
|
|
92
|
+
# --- CPU profiling (py-spy) ---
|
|
93
|
+
p.add_argument(
|
|
94
|
+
"--py-spy-bin",
|
|
95
|
+
metavar="PATH",
|
|
96
|
+
help=f"py-spy executable for the CPU tools (else ${cpu_config.ENV_PY_SPY_BIN}, "
|
|
97
|
+
"else 'py-spy' on PATH or next to this Python)",
|
|
98
|
+
)
|
|
99
|
+
p.add_argument(
|
|
100
|
+
"--cpu-max-duration",
|
|
101
|
+
type=int,
|
|
102
|
+
metavar="SECONDS",
|
|
103
|
+
help=f"longest CPU profile a tool may record (also ${cpu_config.ENV_CPU_MAX_DURATION}; "
|
|
104
|
+
f"default {cpu_config.DEFAULT_CPU_MAX_DURATION})",
|
|
105
|
+
)
|
|
106
|
+
# --- end CPU profiling ---
|
|
107
|
+
return p
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv: list[str] | None = None) -> int:
|
|
111
|
+
args = build_parser().parse_args(argv)
|
|
112
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
113
|
+
handler.setFormatter(logging.Formatter("heapspy-mcp: %(levelname)s %(message)s"))
|
|
114
|
+
log = logging.getLogger("heapspy_mcp")
|
|
115
|
+
log.addHandler(handler)
|
|
116
|
+
log.setLevel(logging.INFO)
|
|
117
|
+
log.propagate = False
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
config = Config.from_sources(
|
|
121
|
+
heapspy_bin=args.heapspy_bin,
|
|
122
|
+
snapshot_dir=args.snapshot_dir,
|
|
123
|
+
allow=args.allow,
|
|
124
|
+
max_str=args.max_str,
|
|
125
|
+
max_rss=args.max_rss,
|
|
126
|
+
timeout=args.timeout,
|
|
127
|
+
capture_timeout=args.capture_timeout,
|
|
128
|
+
)
|
|
129
|
+
config.diagnostics = diagnostics_from_args(args)
|
|
130
|
+
cpu_config.load_cpu_settings(
|
|
131
|
+
config, py_spy_bin=args.py_spy_bin, cpu_max_duration=args.cpu_max_duration
|
|
132
|
+
)
|
|
133
|
+
except ConfigError as e:
|
|
134
|
+
print(f"heapspy-mcp: error: {e}", file=sys.stderr)
|
|
135
|
+
return 2
|
|
136
|
+
|
|
137
|
+
if config.heapspy_bin is None:
|
|
138
|
+
log.warning(
|
|
139
|
+
"heapspy executable not found (no --heapspy-bin, no $%s, not on PATH); "
|
|
140
|
+
"analysis and capture tools will return binary_not_found",
|
|
141
|
+
ENV_BIN,
|
|
142
|
+
)
|
|
143
|
+
log.info(
|
|
144
|
+
"heapspy=%s snapshot_dir=%s allow=%s default_max_str=%s max_rss=%d",
|
|
145
|
+
config.heapspy_bin,
|
|
146
|
+
config.snapshot_dir,
|
|
147
|
+
config.allow or "(none: live capture disabled)",
|
|
148
|
+
config.max_str,
|
|
149
|
+
config.max_rss_bytes,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if config.py_spy_bin is None:
|
|
153
|
+
log.warning("py-spy not found; the CPU tools will return py_spy_not_found")
|
|
154
|
+
log.info("py_spy=%s cpu_max_duration=%ds", config.py_spy_bin, config.cpu_max_duration)
|
|
155
|
+
|
|
156
|
+
from .server import build_server
|
|
157
|
+
|
|
158
|
+
server = build_server(config)
|
|
159
|
+
if args.transport == "stdio":
|
|
160
|
+
server.run("stdio")
|
|
161
|
+
else:
|
|
162
|
+
server.run("streamable-http", host=args.host, port=args.port)
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
sys.exit(main())
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Bounded summaries of heapspy allocation data (``.hpya`` profiles).
|
|
2
|
+
|
|
3
|
+
Two sources:
|
|
4
|
+
|
|
5
|
+
- ``analyze SNAP --allocations FILE --json``: the ``allocations`` block
|
|
6
|
+
(recording header, coverage, ``top_stacks``) and ``allocated_at`` on each
|
|
7
|
+
top retainer; ``query --type ... --allocations`` fills ``allocated_at`` on
|
|
8
|
+
instances. See docs/json-schema.md, "Allocations".
|
|
9
|
+
- ``flamegraph --allocations FILE --folded``: the only output heapspy has for
|
|
10
|
+
a profile without a snapshot. It gives estimated bytes per stack and
|
|
11
|
+
nothing else (no sample counts, event kinds or recording header).
|
|
12
|
+
|
|
13
|
+
Frames are innermost first everywhere in the output, as in the JSON report.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .summarize import MAX_NAME, clip, notes, with_human
|
|
22
|
+
|
|
23
|
+
MAX_FRAMES = 6
|
|
24
|
+
MAX_STACK_HINTS = 3
|
|
25
|
+
MAX_ALLOCATED_AT_STACKS = 3
|
|
26
|
+
MAX_SUMMARY_STACKS = 5
|
|
27
|
+
MAX_FOLDED_LINES = 100_000
|
|
28
|
+
MAX_LOCATION = 160
|
|
29
|
+
|
|
30
|
+
CAVEATS = (
|
|
31
|
+
"Sampled, not complete: only arena, pool and large-object events made while recording "
|
|
32
|
+
"are sampled. Blocks reused from freed memory, and objects allocated before recording "
|
|
33
|
+
"started, have no stack.",
|
|
34
|
+
"Arena and pool links are approximate: they name the most recent growth event that "
|
|
35
|
+
"created the memory, not necessarily the call that allocated the object. "
|
|
36
|
+
"exact_linked_bytes counts links through large-object samples, which are exact: the "
|
|
37
|
+
"object is the sampled allocation itself. Objects on memory a sampled large block "
|
|
38
|
+
"left behind, which they do not match, are not linked (coverage.unlinked_reused_*).",
|
|
39
|
+
"estimated_bytes is the sum of sample weights over the recording window: an estimate "
|
|
40
|
+
"of bytes allocated through sampled events, not of bytes still alive. linked_bytes is "
|
|
41
|
+
"what is still alive in the snapshot.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_IDENT_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(/[A-Za-z_][A-Za-z0-9_]*)*\.py$")
|
|
45
|
+
_FOLDED_FRAME_RE = re.compile(r"^(?P<function>.*) \((?P<location>[^()]*)\)$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# -- frames and hints ----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _module_guess(file: str) -> str | None:
|
|
52
|
+
"""``app/session.py`` -> ``app.session`` for a relative path of identifiers."""
|
|
53
|
+
path = file.replace("\\", "/").removeprefix("./")
|
|
54
|
+
if not _IDENT_PATH_RE.match(path):
|
|
55
|
+
return None
|
|
56
|
+
module = path.removesuffix(".py").replace("/", ".")
|
|
57
|
+
return module.removesuffix(".__init__") or None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def qualname_guess(function: str | None, file: str | None) -> str | None:
|
|
61
|
+
"""A ``module.qualname`` for ``map_to_source`` from a frame, when one can be guessed.
|
|
62
|
+
|
|
63
|
+
Only for relative ``co_filename`` values made of identifiers; the module
|
|
64
|
+
is then the path. For an absolute filename the repository root is not
|
|
65
|
+
known, so there is no guess: read the file at the line instead.
|
|
66
|
+
"""
|
|
67
|
+
if not isinstance(file, str) or not isinstance(function, str):
|
|
68
|
+
return None
|
|
69
|
+
module = _module_guess(file)
|
|
70
|
+
if module is None:
|
|
71
|
+
return None
|
|
72
|
+
if function == "<module>":
|
|
73
|
+
return module
|
|
74
|
+
parts = function.split(".")
|
|
75
|
+
if not all(p.isidentifier() or p == "<locals>" for p in parts) or parts[0] == "<locals>":
|
|
76
|
+
return None
|
|
77
|
+
return f"{module}.{function}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clip_left(text: Any, limit: int = MAX_LOCATION) -> Any:
|
|
81
|
+
"""Clip a path from the left: the file name and line are the useful end."""
|
|
82
|
+
if not isinstance(text, str) or len(text) <= limit:
|
|
83
|
+
return text
|
|
84
|
+
return "..." + text[-(limit - 3) :]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _frame(frame: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
line = frame.get("line")
|
|
89
|
+
approximate = frame.get("approximate")
|
|
90
|
+
return {
|
|
91
|
+
"function": clip(frame.get("function"), MAX_NAME),
|
|
92
|
+
"file": clip_left(frame.get("file")),
|
|
93
|
+
"line": line if isinstance(line, int) else None,
|
|
94
|
+
"location": clip_left(frame.get("location")),
|
|
95
|
+
# None in folded output, which does not carry the flag.
|
|
96
|
+
"approximate": approximate if isinstance(approximate, bool) else None,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def stack_source_hints(frames: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
101
|
+
"""Where to look, innermost frame first: ``file:line`` and a qualname guess.
|
|
102
|
+
|
|
103
|
+
The first hint is the allocating function itself; the next ones are its
|
|
104
|
+
callers, which are often where the decision to keep the object is made.
|
|
105
|
+
"""
|
|
106
|
+
hints: list[dict[str, Any]] = []
|
|
107
|
+
for f in frames:
|
|
108
|
+
if len(hints) >= MAX_STACK_HINTS:
|
|
109
|
+
break
|
|
110
|
+
file, line = f.get("file"), f.get("line")
|
|
111
|
+
if not isinstance(file, str) or not file or file.startswith("<"):
|
|
112
|
+
continue
|
|
113
|
+
hints.append(
|
|
114
|
+
{
|
|
115
|
+
"location": f.get("location") or f"{file}:{line if line is not None else '?'}",
|
|
116
|
+
"file": file,
|
|
117
|
+
"line": line,
|
|
118
|
+
"function": f.get("function"),
|
|
119
|
+
"qualname_guess": qualname_guess(f.get("function"), file),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
return hints
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _frames(raw: Any) -> tuple[list[dict[str, Any]], int]:
|
|
126
|
+
frames = [f for f in raw or [] if isinstance(f, dict)]
|
|
127
|
+
return [_frame(f) for f in frames[:MAX_FRAMES]], len(frames)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _stack_common(frames_raw: Any) -> dict[str, Any]:
|
|
131
|
+
frames, total = _frames(frames_raw)
|
|
132
|
+
out: dict[str, Any] = {
|
|
133
|
+
"allocated_in": frames[0]["location"] if frames else None,
|
|
134
|
+
"frames": frames,
|
|
135
|
+
"frames_total": total,
|
|
136
|
+
"source_hints": stack_source_hints(frames),
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# -- analyze / query reports ---------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def summarize_top_stack(stack: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
item: dict[str, Any] = {
|
|
146
|
+
"rank": stack.get("rank"),
|
|
147
|
+
"estimated_bytes": stack.get("estimated_bytes"),
|
|
148
|
+
"samples": stack.get("samples"),
|
|
149
|
+
"events": list(stack.get("events") or [])[:8],
|
|
150
|
+
"linked_bytes": stack.get("linked_bytes"),
|
|
151
|
+
"exact_linked_bytes": stack.get("exact_linked_bytes"),
|
|
152
|
+
"approximate": stack.get("approximate"),
|
|
153
|
+
**_stack_common(stack.get("frames")),
|
|
154
|
+
}
|
|
155
|
+
return with_human(item, "estimated_bytes", "linked_bytes", "exact_linked_bytes")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def summarize_allocated_at(
|
|
159
|
+
at: dict[str, Any] | None, max_stacks: int = MAX_ALLOCATED_AT_STACKS
|
|
160
|
+
) -> dict[str, Any] | None:
|
|
161
|
+
"""``allocated_at`` of a retainer or instance, with at most ``max_stacks`` stacks."""
|
|
162
|
+
if not isinstance(at, dict):
|
|
163
|
+
return None
|
|
164
|
+
raw = [s for s in at.get("stacks") or [] if isinstance(s, dict)]
|
|
165
|
+
stacks = []
|
|
166
|
+
for s in raw[:max_stacks]:
|
|
167
|
+
item = {
|
|
168
|
+
"linked_objects": s.get("linked_objects"),
|
|
169
|
+
"linked_bytes": s.get("linked_bytes"),
|
|
170
|
+
"exact_linked_bytes": s.get("exact_linked_bytes"),
|
|
171
|
+
"top_stack_rank": s.get("top_stack_rank"),
|
|
172
|
+
**_stack_common(s.get("frames")),
|
|
173
|
+
}
|
|
174
|
+
stacks.append(with_human(item, "linked_bytes"))
|
|
175
|
+
out = {
|
|
176
|
+
"linked_objects": at.get("linked_objects"),
|
|
177
|
+
"linked_bytes": at.get("linked_bytes"),
|
|
178
|
+
"exact_linked_bytes": at.get("exact_linked_bytes"),
|
|
179
|
+
"approximate": at.get("approximate"),
|
|
180
|
+
"stacks": stacks,
|
|
181
|
+
"stacks_total": len(raw),
|
|
182
|
+
}
|
|
183
|
+
return with_human(out, "linked_bytes", "exact_linked_bytes")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def allocation_notes(items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
187
|
+
"""The notes about the profile (``allocations_*`` codes)."""
|
|
188
|
+
return [
|
|
189
|
+
n
|
|
190
|
+
for n in notes(items)
|
|
191
|
+
if isinstance(n.get("code"), str) and n["code"].startswith("allocations_")
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _not_linked_warning(note_list: list[dict[str, Any]]) -> str | None:
|
|
196
|
+
if any(n.get("code") == "allocations_not_linked" for n in note_list):
|
|
197
|
+
return (
|
|
198
|
+
"No object was linked to a sample: the profile does not match the snapshot (see "
|
|
199
|
+
"the allocations_* notes: another pid, another CPython version, or a snapshot "
|
|
200
|
+
"taken before recording started). top_stacks still shows estimated bytes, but "
|
|
201
|
+
"linked_bytes is 0 everywhere."
|
|
202
|
+
)
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def summarize_allocations(
|
|
207
|
+
block: dict[str, Any] | None, doc_notes: list[dict[str, Any]] | None, top: int
|
|
208
|
+
) -> dict[str, Any] | None:
|
|
209
|
+
"""The ``allocations`` block of an analyze report, bounded to ``top`` stacks."""
|
|
210
|
+
if not isinstance(block, dict):
|
|
211
|
+
return None
|
|
212
|
+
sampling = block.get("sampling") if isinstance(block.get("sampling"), dict) else {}
|
|
213
|
+
overhead = block.get("overhead") if isinstance(block.get("overhead"), dict) else {}
|
|
214
|
+
coverage = dict(block.get("coverage") or {})
|
|
215
|
+
with_human(coverage, "linked_bytes", "exact_linked_bytes", "unlinked_reused_bytes")
|
|
216
|
+
recording = {
|
|
217
|
+
"path": clip_left(block.get("path"), 400),
|
|
218
|
+
"pid": block.get("pid"),
|
|
219
|
+
"python_version": block.get("python_version"),
|
|
220
|
+
"started_at": block.get("started_at"),
|
|
221
|
+
"duration_ms": block.get("duration_ms"),
|
|
222
|
+
"mechanisms": list(block.get("mechanisms") or [])[:8],
|
|
223
|
+
"sampling": {
|
|
224
|
+
"events": list(sampling.get("events") or [])[:8],
|
|
225
|
+
"byte_interval": sampling.get("byte_interval"),
|
|
226
|
+
"max_frames": sampling.get("max_frames"),
|
|
227
|
+
},
|
|
228
|
+
"overhead": {
|
|
229
|
+
k: overhead.get(k)
|
|
230
|
+
for k in ("samples_taken", "samples_dropped", "probe_hits", "total_pause_ns")
|
|
231
|
+
},
|
|
232
|
+
"truncated": block.get("truncated"),
|
|
233
|
+
"approximate": block.get("approximate"),
|
|
234
|
+
"samples": block.get("samples"),
|
|
235
|
+
"estimated_bytes": block.get("estimated_bytes"),
|
|
236
|
+
}
|
|
237
|
+
with_human(recording, "estimated_bytes")
|
|
238
|
+
stacks = [s for s in block.get("top_stacks") or [] if isinstance(s, dict)]
|
|
239
|
+
note_list = allocation_notes(doc_notes)
|
|
240
|
+
out: dict[str, Any] = {
|
|
241
|
+
"recording": recording,
|
|
242
|
+
"coverage": coverage,
|
|
243
|
+
"top_stacks": [summarize_top_stack(s) for s in stacks[:top]],
|
|
244
|
+
"top_stacks_listed": len(stacks),
|
|
245
|
+
"notes": note_list,
|
|
246
|
+
"caveats": list(CAVEATS),
|
|
247
|
+
}
|
|
248
|
+
warning = _not_linked_warning(note_list)
|
|
249
|
+
if warning:
|
|
250
|
+
out["warning"] = warning
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def add_to_analyze(summary: dict[str, Any], doc: dict[str, Any], top: int) -> dict[str, Any]:
|
|
255
|
+
"""Add the allocation join of an analyze report to its summary (in place)."""
|
|
256
|
+
block = doc.get("allocations")
|
|
257
|
+
if not isinstance(block, dict):
|
|
258
|
+
return summary
|
|
259
|
+
raw = [r for r in doc.get("top_retainers") or [] if isinstance(r, dict)]
|
|
260
|
+
for item, r in zip(summary.get("top_retainers") or [], raw, strict=False):
|
|
261
|
+
item["allocated_at"] = summarize_allocated_at(r.get("allocated_at"))
|
|
262
|
+
summary["allocations"] = summarize_allocations(
|
|
263
|
+
block, doc.get("notes"), min(top, MAX_SUMMARY_STACKS)
|
|
264
|
+
)
|
|
265
|
+
return summary
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def add_to_type_query(summary: dict[str, Any], doc: dict[str, Any]) -> dict[str, Any]:
|
|
269
|
+
"""Add ``allocated_at`` of each instance of a ``query --type --allocations`` report."""
|
|
270
|
+
raw = [i for i in doc.get("top_instances") or [] if isinstance(i, dict)]
|
|
271
|
+
for item, inst in zip(summary.get("top_instances") or [], raw, strict=False):
|
|
272
|
+
item["allocated_at"] = summarize_allocated_at(inst.get("allocated_at"))
|
|
273
|
+
note_list = allocation_notes(doc.get("notes"))
|
|
274
|
+
out: dict[str, Any] = {"notes": note_list, "caveats": list(CAVEATS)}
|
|
275
|
+
warning = _not_linked_warning(note_list)
|
|
276
|
+
if warning:
|
|
277
|
+
out["warning"] = warning
|
|
278
|
+
summary["allocations"] = out
|
|
279
|
+
return summary
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# -- profile only: folded stacks -----------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_folded_frame(text: str) -> dict[str, Any]:
|
|
286
|
+
m = _FOLDED_FRAME_RE.match(text)
|
|
287
|
+
if not m:
|
|
288
|
+
# "[no Python stack]" and anything unexpected.
|
|
289
|
+
return {"function": text, "file": None, "line": None, "location": None}
|
|
290
|
+
function, location = m.group("function"), m.group("location")
|
|
291
|
+
file: str | None = None
|
|
292
|
+
line: int | None = None
|
|
293
|
+
if location != "<unknown>":
|
|
294
|
+
head, sep, tail = location.rpartition(":")
|
|
295
|
+
if sep and (tail.isdigit() or tail == "?"):
|
|
296
|
+
file = head
|
|
297
|
+
line = int(tail) if tail.isdigit() else None
|
|
298
|
+
else:
|
|
299
|
+
file = location
|
|
300
|
+
return {"function": function, "file": file, "line": line, "location": location}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def parse_folded(text: str) -> list[tuple[list[dict[str, Any]], int]]:
|
|
304
|
+
"""``outer;...;inner bytes`` lines as (frames innermost first, bytes)."""
|
|
305
|
+
out: list[tuple[list[dict[str, Any]], int]] = []
|
|
306
|
+
for raw in text.splitlines()[:MAX_FOLDED_LINES]:
|
|
307
|
+
line = raw.rstrip()
|
|
308
|
+
stack, sep, weight = line.rpartition(" ")
|
|
309
|
+
if not sep or not weight.isdigit() or not stack:
|
|
310
|
+
continue
|
|
311
|
+
frames = [_parse_folded_frame(f) for f in stack.split(";")]
|
|
312
|
+
frames.reverse()
|
|
313
|
+
out.append((frames, int(weight)))
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def summarize_folded(text: str, top: int) -> dict[str, Any]:
|
|
318
|
+
"""Top stacks of a profile from its folded stacks, merged by identical frames."""
|
|
319
|
+
merged: dict[tuple[tuple[Any, ...], ...], int] = {}
|
|
320
|
+
order: dict[tuple[tuple[Any, ...], ...], list[dict[str, Any]]] = {}
|
|
321
|
+
for frames, weight in parse_folded(text):
|
|
322
|
+
key = tuple((f["function"], f["location"]) for f in frames)
|
|
323
|
+
merged[key] = merged.get(key, 0) + weight
|
|
324
|
+
order.setdefault(key, frames)
|
|
325
|
+
ranked = sorted(merged.items(), key=lambda kv: -kv[1])
|
|
326
|
+
total = sum(merged.values())
|
|
327
|
+
stacks = []
|
|
328
|
+
for rank, (key, weight) in enumerate(ranked[:top], start=1):
|
|
329
|
+
frames = [{**f, "approximate": None} for f in order[key]]
|
|
330
|
+
item: dict[str, Any] = {
|
|
331
|
+
"rank": rank,
|
|
332
|
+
"estimated_bytes": weight,
|
|
333
|
+
"estimated_pct": round(weight / total * 100, 2) if total else None,
|
|
334
|
+
# Not in folded output; available with a snapshot.
|
|
335
|
+
"samples": None,
|
|
336
|
+
"events": None,
|
|
337
|
+
"linked_bytes": None,
|
|
338
|
+
"exact_linked_bytes": None,
|
|
339
|
+
**_stack_common(frames),
|
|
340
|
+
}
|
|
341
|
+
stacks.append(with_human(item, "estimated_bytes"))
|
|
342
|
+
out = {
|
|
343
|
+
"estimated_bytes": total,
|
|
344
|
+
"stacks_total": len(merged),
|
|
345
|
+
"top_stacks": stacks,
|
|
346
|
+
}
|
|
347
|
+
return with_human(out, "estimated_bytes")
|