Files
pristontale-eu-linux/ptdiag/trace.py
2026-08-27 17:50:36 +02:00

243 lines
6.8 KiB
Python

"""Streaming merge and normalization for per-PID strace file traces."""
from __future__ import annotations
import ast
from collections import Counter
from contextlib import ExitStack
from dataclasses import asdict, dataclass
import heapq
import json
from pathlib import Path
import re
from typing import Iterator, TextIO
_TIMESTAMP_RE = re.compile(r"^(?P<timestamp>[0-9]+(?:\.[0-9]+)?)\s+(?P<body>.*)$")
_SYSCALL_RE = re.compile(r"^(?P<syscall>[A-Za-z_][A-Za-z0-9_]*)\(")
_ERRNO_RE = re.compile(
r"=\s+-1\s+(?P<errno>E[A-Z0-9]+)\s+\((?P<description>[^)]*)\)"
)
_QUOTED_RE = re.compile(r'"(?:\\.|[^"\\])*"')
_ERROR_CLASSIFICATIONS = {
"ENOENT": "missing",
"ENOTDIR": "path-component-not-directory",
"EACCES": "permission-denied",
"EPERM": "operation-not-permitted",
"ELOOP": "symlink-loop",
"ENAMETOOLONG": "name-too-long",
"EROFS": "read-only-filesystem",
"EIO": "io-error",
}
@dataclass(frozen=True)
class TraceSummary:
shards: int
lines: int
normalized_records: int
focused_lines: int
error_counts: dict[str, int]
top_errors: list[dict[str, object]]
merged_path: Path
normalized_path: Path
focused_path: Path
focused_json_path: Path
def to_dict(self) -> dict[str, object]:
result = asdict(self)
for key in (
"merged_path",
"normalized_path",
"focused_path",
"focused_json_path",
):
result[key] = str(result[key])
return result
def process_trace_shards(
raw_directory: Path,
result_directory: Path,
*,
trigger_epoch: float,
focus_before_seconds: float,
focus_after_seconds: float,
live_game: Path,
) -> TraceSummary:
shards = sorted(
(path for path in raw_directory.glob("trace.*") if path.is_file()),
key=_shard_pid,
)
merged_path = result_directory / "merged.txt"
normalized_path = result_directory / "filesystem.jsonl"
focused_path = result_directory / "focused.txt"
focused_json_path = result_directory / "focused.jsonl"
error_counts: Counter[str] = Counter()
error_paths: Counter[tuple[str, str, str, str | None]] = Counter()
line_count = 0
normalized_count = 0
focused_count = 0
focus_start = trigger_epoch - focus_before_seconds
focus_end = trigger_epoch + focus_after_seconds
with (
merged_path.open("w", encoding="utf-8") as merged,
normalized_path.open("w", encoding="utf-8") as normalized,
focused_path.open("w", encoding="utf-8") as focused,
focused_json_path.open("w", encoding="utf-8") as focused_json,
):
for timestamp, pid, raw in _merge_shards(shards):
line_count += 1
body = _body(raw)
merged_line = f"{timestamp:.6f} [{pid}] {body}\n"
merged.write(merged_line)
record = parse_trace_line(pid, raw, live_game)
if record is not None:
normalized_count += 1
normalized.write(json.dumps(record, sort_keys=True))
normalized.write("\n")
errno = record.get("errno")
classification = record.get("error_classification")
if isinstance(errno, str) and isinstance(classification, str):
error_counts[classification] += 1
paths = record.get("paths")
path = paths[0] if isinstance(paths, list) and paths else ""
game_relative = record.get("game_relative")
error_paths[
(
classification,
errno,
str(path),
str(game_relative) if game_relative is not None else None,
)
] += 1
if focus_start <= timestamp <= focus_end:
focused_count += 1
focused.write(merged_line)
if record is not None:
focused_json.write(json.dumps(record, sort_keys=True))
focused_json.write("\n")
top_errors = [
{
"classification": key[0],
"errno": key[1],
"path": key[2],
"game_relative": key[3],
"count": count,
}
for key, count in error_paths.most_common(200)
]
return TraceSummary(
shards=len(shards),
lines=line_count,
normalized_records=normalized_count,
focused_lines=focused_count,
error_counts=dict(sorted(error_counts.items())),
top_errors=top_errors,
merged_path=merged_path,
normalized_path=normalized_path,
focused_path=focused_path,
focused_json_path=focused_json_path,
)
def parse_trace_line(pid: int, line: str, live_game: Path) -> dict[str, object] | None:
match = _TIMESTAMP_RE.match(line.rstrip("\n"))
if match is None:
return None
timestamp = float(match.group("timestamp"))
body = match.group("body")
syscall_match = _SYSCALL_RE.match(body)
if syscall_match is None:
return None
paths = [_decode_quoted(item) for item in _QUOTED_RE.findall(body)]
paths = [item for item in paths if item is not None]
errno_match = _ERRNO_RE.search(body)
errno = errno_match.group("errno") if errno_match is not None else None
classification = (
_ERROR_CLASSIFICATIONS.get(errno, "other-error") if errno is not None else None
)
game_relative = next(
(
_relative_game_path(path, live_game)
for path in paths
if _relative_game_path(path, live_game) is not None
),
None,
)
return {
"timestamp": timestamp,
"pid": pid,
"syscall": syscall_match.group("syscall"),
"paths": paths,
"errno": errno,
"error_classification": classification,
"game_relative": game_relative,
"raw": body,
}
def _merge_shards(paths: list[Path]) -> Iterator[tuple[float, int, str]]:
with ExitStack() as stack:
iterators: list[Iterator[tuple[float, int, str]]] = []
for path in paths:
handle = stack.enter_context(path.open("r", encoding="utf-8", errors="replace"))
iterators.append(_iter_shard(handle, _shard_pid(path)))
heap: list[tuple[float, int, int, str]] = []
for index, iterator in enumerate(iterators):
try:
timestamp, pid, raw = next(iterator)
except StopIteration:
continue
heapq.heappush(heap, (timestamp, pid, index, raw))
while heap:
timestamp, pid, index, raw = heapq.heappop(heap)
yield timestamp, pid, raw
try:
next_timestamp, next_pid, next_raw = next(iterators[index])
except StopIteration:
continue
heapq.heappush(heap, (next_timestamp, next_pid, index, next_raw))
def _iter_shard(handle: TextIO, pid: int) -> Iterator[tuple[float, int, str]]:
last_timestamp = 0.0
for raw in handle:
match = _TIMESTAMP_RE.match(raw)
if match is not None:
last_timestamp = float(match.group("timestamp"))
yield last_timestamp, pid, raw
def _shard_pid(path: Path) -> int:
try:
return int(path.name.rsplit(".", 1)[1])
except (IndexError, ValueError):
return 0
def _body(raw: str) -> str:
match = _TIMESTAMP_RE.match(raw.rstrip("\n"))
return match.group("body") if match is not None else raw.rstrip("\n")
def _decode_quoted(value: str) -> str | None:
try:
decoded = ast.literal_eval(value)
except (SyntaxError, ValueError):
return None
return decoded if isinstance(decoded, str) else None
def _relative_game_path(value: str, live_game: Path) -> str | None:
normalized = value.replace("\\", "/")
root = str(live_game).replace("\\", "/").rstrip("/")
index = normalized.casefold().find(root.casefold())
if index < 0:
return None
relative = normalized[index + len(root) :].lstrip("/")
return relative or "."