#!/usr/bin/env python3 import argparse import json import os import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] HELPER_PROJECT = ROOT / "tools" / "DtpSnapshotExtractor" / "DtpSnapshotExtractor.csproj" HELPER_DLL = ROOT / "tools" / "DtpSnapshotExtractor" / "bin" / "Debug" / "net10.0" / "DtpSnapshotExtractor.dll" DEFAULT_DOTTRACE_DIR = Path.home() / "Applications" / "dotTrace.app" / "Contents" / "DotFiles" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Parse a raw dotTrace .dtp snapshot family into JSON call-tree data." ) parser.add_argument("snapshot", help="Path to the .dtp snapshot index file.") parser.add_argument("--out", help="Write JSON to this file.") parser.add_argument("--stdout", action="store_true", help="Write JSON to stdout.") parser.add_argument("--top", type=int, default=200, help="Maximum hotspot and path entries to emit.") parser.add_argument("--filter", dest="name_filter", help="Case-insensitive substring filter for node names.") parser.add_argument( "--flat", "--paths", dest="flat_paths", action="store_true", help="Include the top heaviest call paths as flat strings.", ) idle_group = parser.add_mutually_exclusive_group() idle_group.add_argument( "--exclude-idle", dest="exclude_idle", action="store_true", default=True, help="Exclude idle and wait methods from hotspot and path rankings.", ) idle_group.add_argument( "--include-idle", dest="exclude_idle", action="store_false", help="Keep idle and wait methods in hotspot and path rankings.", ) return parser.parse_args() def find_dottrace_dir() -> Path: value = os.environ.get("DOTTRACE_APP_DIR") if value: candidate = Path(value).expanduser() if candidate.is_dir(): return candidate if DEFAULT_DOTTRACE_DIR.is_dir(): return DEFAULT_DOTTRACE_DIR raise FileNotFoundError( f"dotTrace assemblies not found. Set DOTTRACE_APP_DIR or install dotTrace under {DEFAULT_DOTTRACE_DIR}." ) def build_helper(dottrace_dir: Path) -> None: command = [ "dotnet", "build", str(HELPER_PROJECT), "-nologo", "-clp:ErrorsOnly", f"-p:DotTraceAppDir={dottrace_dir}", ] result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "dotnet build failed") def run_helper(snapshot: Path, dottrace_dir: Path, args: argparse.Namespace) -> dict: command = ["dotnet", str(HELPER_DLL), str(snapshot), "--top", str(args.top)] if args.name_filter: command.extend(["--filter", args.name_filter]) if args.flat_paths: command.append("--flat") if not args.exclude_idle: command.append("--include-idle") env = os.environ.copy() env["DOTTRACE_APP_DIR"] = str(dottrace_dir) result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False, env=env) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "extractor failed") return json.loads(result.stdout) def main() -> int: args = parse_args() snapshot = Path(args.snapshot).expanduser().resolve() if not snapshot.is_file(): print(f"Snapshot not found: {snapshot}", file=sys.stderr) return 2 if not args.stdout and not args.out: args.stdout = True try: dottrace_dir = find_dottrace_dir() build_helper(dottrace_dir) payload = run_helper(snapshot, dottrace_dir, args) except Exception as exc: # noqa: BLE001 print(str(exc), file=sys.stderr) return 1 text = json.dumps(payload, indent=2) if args.out: out_path = Path(args.out).expanduser().resolve() out_path.write_text(text + "\n", encoding="utf-8") if args.stdout: sys.stdout.write(text + "\n") return 0 if __name__ == "__main__": raise SystemExit(main())