104 lines
3.2 KiB
Python
104 lines
3.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Scan Unreal/client/server logs for critical 30-minute-soak failures."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
CRITICAL_PATTERNS = [
|
|
re.compile(pattern, re.IGNORECASE)
|
|
for pattern in [
|
|
r"\bFatal\b",
|
|
r"\bCritical\b",
|
|
r"\bCrash\b",
|
|
r"\bAssert(?:ion)?\b",
|
|
r"\bEnsure condition failed\b",
|
|
r"\bUnhandled Exception\b",
|
|
r"\bAccess violation\b",
|
|
r"\bLogOutputDevice:\s*Error\b",
|
|
r"\bLogWindows:\s*Error\b",
|
|
r"\bLogLinux:\s*Error\b",
|
|
r"\bCallstack\b",
|
|
]
|
|
]
|
|
|
|
# Keep this narrow. Add entries only for known benign engine noise with a commit
|
|
# note explaining why the line is allowed.
|
|
ALLOWLIST_PATTERNS = [
|
|
re.compile(r"LogWindows: Failed to load 'aqProf.dll'", re.IGNORECASE),
|
|
re.compile(r"LogWindows: Failed to load 'VtuneApi\.dll'", re.IGNORECASE),
|
|
re.compile(r"LogInit:\s+-crashhandlerstacksize\b", re.IGNORECASE),
|
|
re.compile(r"LogInit:\s+-allowsyscallfilterfile=.*will\*? cause a crash", re.IGNORECASE),
|
|
]
|
|
|
|
DEFAULT_LOG_SUFFIXES = {".log", ".txt"}
|
|
|
|
|
|
def iter_log_files(paths: list[Path]) -> list[Path]:
|
|
files: list[Path] = []
|
|
for path in paths:
|
|
if path.is_dir():
|
|
files.extend(
|
|
candidate
|
|
for candidate in path.rglob("*")
|
|
if candidate.is_file() and candidate.suffix.lower() in DEFAULT_LOG_SUFFIXES
|
|
)
|
|
elif path.is_file():
|
|
files.append(path)
|
|
else:
|
|
raise FileNotFoundError(f"Log path does not exist: {path}")
|
|
return sorted(set(files))
|
|
|
|
|
|
def is_allowed(line: str) -> bool:
|
|
return any(pattern.search(line) for pattern in ALLOWLIST_PATTERNS)
|
|
|
|
|
|
def is_critical(line: str) -> bool:
|
|
return any(pattern.search(line) for pattern in CRITICAL_PATTERNS) and not is_allowed(line)
|
|
|
|
|
|
def scan_file(path: Path) -> list[tuple[int, str]]:
|
|
matches: list[tuple[int, str]] = []
|
|
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
for line_number, line in enumerate(handle, start=1):
|
|
if is_critical(line):
|
|
matches.append((line_number, line.rstrip()))
|
|
return matches
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("paths", nargs="+", type=Path, help="Log files or directories to scan.")
|
|
args = parser.parse_args()
|
|
|
|
log_files = iter_log_files(args.paths)
|
|
if not log_files:
|
|
print("ERROR: no .log or .txt files found to scan.", file=sys.stderr)
|
|
return 2
|
|
|
|
failures: list[str] = []
|
|
for log_file in log_files:
|
|
matches = scan_file(log_file)
|
|
for line_number, line in matches:
|
|
failures.append(f"{log_file}:{line_number}: {line}")
|
|
|
|
if failures:
|
|
print("FAILED: critical log spam detected.")
|
|
for failure in failures[:200]:
|
|
print(failure)
|
|
if len(failures) > 200:
|
|
print(f"... {len(failures) - 200} additional matches suppressed")
|
|
return 1
|
|
|
|
print(f"OK: scanned {len(log_files)} log file(s); no critical log spam detected.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|