06999337f7
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
549 lines
20 KiB
Python
549 lines
20 KiB
Python
"""Friendly crash reporting with one-tap GitHub issue filing.
|
|
|
|
Replaces Python's default wall-of-red traceback with a calm, branded
|
|
crash screen (see :mod:`omnigent.crash_ui`) and lets the user file a
|
|
GitHub issue from the repo's pre-filled bug-report template.
|
|
|
|
Design notes
|
|
------------
|
|
* **No token is shipped.** We can't embed a GitHub credential in a
|
|
distributed binary — anyone could extract it. Instead we open the
|
|
repo's bug-report template in the browser with the title, version,
|
|
OS, and the full traceback pre-filled into the Description field via
|
|
URL query params. The clipboard carries the full report as a backup
|
|
in case the URL is too long and the description gets truncated.
|
|
* **One chokepoint.** ``sys.excepthook`` (main thread) +
|
|
``threading.excepthook`` (background threads) + ``faulthandler``
|
|
(C-level segfaults, captured to a file since the process is already
|
|
dying) cover every normal crash path.
|
|
* **TTY-aware.** The interactive "file a bug?" prompt only runs when
|
|
stdin AND stderr are real TTYs, so scripts/CI never hang — they get
|
|
the saved report path and the issue link printed plainly.
|
|
* **KeyboardInterrupt / SystemExit** are not crashes: they defer to the
|
|
original hooks so Ctrl-C and normal exits behave exactly as before.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import datetime
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import traceback
|
|
import urllib.parse
|
|
import webbrowser
|
|
from pathlib import Path
|
|
from types import TracebackType
|
|
from typing import BinaryIO, TextIO, TypedDict
|
|
|
|
from omnigent.crash_ui import real_stderr, render_crash_screen
|
|
from omnigent.process_logging import data_dir
|
|
|
|
# Saved at install time so we can defer to the originals for
|
|
# KeyboardInterrupt / SystemExit and as a last-resort fallback if our
|
|
# own handler misfires.
|
|
_ORIG_EXCEPTHOOK = sys.__excepthook__
|
|
_ORIG_THREADING_EXCEPTHOOK = threading.excepthook
|
|
|
|
|
|
# Runtime configuration, populated by install_crash_handler().
|
|
class _CrashConfig(TypedDict):
|
|
app_name: str
|
|
repo: str
|
|
version: str
|
|
crashes_dir: str | None
|
|
keep_reports: int
|
|
first_party_prefixes: tuple[str, ...]
|
|
|
|
|
|
_CONFIG: _CrashConfig = {
|
|
"app_name": "omnigent",
|
|
"repo": "omnigent-ai/omnigent",
|
|
"version": "unknown",
|
|
"crashes_dir": None,
|
|
"keep_reports": 10,
|
|
"first_party_prefixes": ("omnigent",),
|
|
}
|
|
|
|
# Reentrancy guard: if handling a crash itself raises, we must not
|
|
# recurse forever through excepthook. Per-thread so a crash in one
|
|
# thread can't block another's reporting.
|
|
_HANDLING = threading.local()
|
|
|
|
# File handle kept open for faulthandler so C-level segfaults dump to a
|
|
# file (out of the terminal) instead of screaming at the user.
|
|
_FH_FILE: BinaryIO | None = None
|
|
|
|
# Maximum total URL length for the pre-filled GitHub issue link. GitHub
|
|
# and some browsers reject or silently truncate very long URLs (a deep
|
|
# recursion crash can produce a many-KB traceback). 8000 chars is the
|
|
# widely-cited safe limit for cross-browser compatibility; the clipboard
|
|
# always carries the full report as a backup when we truncate.
|
|
_MAX_URL_LENGTH = 8000
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Installation
|
|
# --------------------------------------------------------------------------- #
|
|
def install_crash_handler(
|
|
app_name: str,
|
|
repo: str,
|
|
*,
|
|
version: str | None = None,
|
|
crashes_dir: str | Path | None = None,
|
|
keep_reports: int = 10,
|
|
enable_faulthandler: bool = True,
|
|
first_party_prefixes: tuple[str, ...] = ("omnigent",),
|
|
) -> None:
|
|
"""Install the friendly crash handler.
|
|
|
|
Call once, as early as possible in the binary's entrypoint, so
|
|
unhandled exceptions anywhere downstream are caught.
|
|
|
|
:param app_name: Human name shown in the crash header (``omnigent``).
|
|
:param repo: ``owner/repo`` for the GitHub issues URL.
|
|
:param version: App version string for the report. Defaults to
|
|
``omnigent.version.VERSION``.
|
|
:param crashes_dir: Where to write ``crash-*.md`` reports. Defaults
|
|
to ``<data-dir>/crashes`` (honors
|
|
``OMNIGENT_DATA_DIR``).
|
|
:param keep_reports: Rotate to keep at most this many crash reports.
|
|
:param enable_faulthandler: Capture C-level segfaults to a file
|
|
(off the terminal) instead of the default stderr
|
|
dump.
|
|
:param first_party_prefixes: Top-level package prefixes treated as
|
|
own code in the compact traceback (always shown,
|
|
never collapsed — even when installed under
|
|
site-packages in a distributed wheel). Defaults to
|
|
``("omnigent")``, which covers the three core
|
|
packages (``omnigent``, ``omnigent_client``,
|
|
``omnigent_ui_sdk``) via the ``<prefix>_`` rule.
|
|
"""
|
|
_CONFIG["app_name"] = app_name
|
|
_CONFIG["repo"] = repo
|
|
_CONFIG["version"] = version if version is not None else _read_version()
|
|
_CONFIG["crashes_dir"] = str(crashes_dir) if crashes_dir else None
|
|
_CONFIG["keep_reports"] = keep_reports
|
|
_CONFIG["first_party_prefixes"] = tuple(first_party_prefixes) or ("omnigent",)
|
|
sys.excepthook = _excepthook
|
|
threading.excepthook = _threading_excepthook
|
|
if enable_faulthandler:
|
|
_enable_faulthandler()
|
|
|
|
|
|
def _read_version() -> str:
|
|
try:
|
|
from omnigent.version import VERSION
|
|
|
|
return VERSION
|
|
except Exception: # noqa: BLE001 pragma: no cover
|
|
return "unknown"
|
|
|
|
|
|
def _crashes_dir() -> Path:
|
|
override = _CONFIG.get("crashes_dir")
|
|
if override:
|
|
return Path(override).expanduser()
|
|
return data_dir() / "crashes"
|
|
|
|
|
|
def _enable_faulthandler() -> None:
|
|
"""Route C-level segfault dumps to a file, off the terminal.
|
|
|
|
Our Python excepthook can't run after a segfault (the process is
|
|
already dying), so we can't make that pretty or interactive. But we
|
|
can at least keep the faulthandler dump out of the user's terminal
|
|
by redirecting it to ``<crashes>/faulthandler.log``.
|
|
"""
|
|
global _FH_FILE
|
|
try:
|
|
import faulthandler
|
|
|
|
# Close any handle left open by a previous install (e.g. repeated
|
|
# ``main()`` calls in the test suite) so we never leak file
|
|
# descriptors across reinstalls.
|
|
if _FH_FILE is not None:
|
|
with contextlib.suppress(Exception): # pragma: no cover
|
|
_FH_FILE.close()
|
|
d = _crashes_dir()
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
# Intentionally held open for the process lifetime: faulthandler
|
|
# writes here on a segfault, when we can't run cleanup code.
|
|
f = open(d / "faulthandler.log", "ab", buffering=0) # noqa: SIM115
|
|
faulthandler.enable(file=f, all_threads=True)
|
|
_FH_FILE = f
|
|
except Exception: # noqa: BLE001 pragma: no cover
|
|
pass
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Hooks
|
|
# --------------------------------------------------------------------------- #
|
|
def _excepthook(
|
|
etype: type[BaseException], value: BaseException, tb: TracebackType | None
|
|
) -> None:
|
|
# Ctrl-C and normal exits are not crashes — defer to the originals.
|
|
if issubclass(etype, (KeyboardInterrupt, SystemExit)):
|
|
_ORIG_EXCEPTHOOK(etype, value, tb)
|
|
return
|
|
handle_crash(value, tb=tb, source="uncaught")
|
|
# After an uncaught exception in the main thread the interpreter
|
|
# exits with code 1 once this hook returns — no explicit exit needed.
|
|
|
|
|
|
def _threading_excepthook(args: threading.ExceptHookArgs) -> None:
|
|
if issubclass(args.exc_type, (KeyboardInterrupt, SystemExit)):
|
|
_ORIG_THREADING_EXCEPTHOOK(args)
|
|
return
|
|
if args.exc_value is None:
|
|
_ORIG_THREADING_EXCEPTHOOK(args)
|
|
return
|
|
# A crashed background thread shouldn't block the main thread with
|
|
# an interactive prompt (racy and surprising). Save the report and
|
|
# print a concise notice + link instead.
|
|
name = getattr(getattr(args, "thread", None), "name", "?")
|
|
handle_crash(
|
|
args.exc_value,
|
|
tb=args.exc_traceback,
|
|
source=f"thread:{name}",
|
|
interactive=False,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Core: render screen, save report, offer to file a bug
|
|
# --------------------------------------------------------------------------- #
|
|
def handle_crash(
|
|
exc: BaseException,
|
|
*,
|
|
tb: TracebackType | None = None,
|
|
source: str = "uncaught",
|
|
interactive: bool | None = None,
|
|
) -> None:
|
|
"""Handle one uncaught exception end-to-end.
|
|
|
|
1. Build + save a GitHub-ready crash report (rotated).
|
|
2. Print the calm crash screen + de-emphasized traceback.
|
|
3. If interactive, prompt to file a GitHub issue; on yes, open a
|
|
pre-filled bug-report page (traceback, version, OS in the URL).
|
|
The clipboard gets the full report as a backup.
|
|
|
|
Reentrant-safe: a crash inside this function falls back to the
|
|
original excepthook rather than looping forever.
|
|
"""
|
|
if getattr(_HANDLING, "on", False):
|
|
return
|
|
_HANDLING.on = True
|
|
try:
|
|
stream = real_stderr()
|
|
# Full, unmodified traceback for the saved report (real paths, every
|
|
# frame — what a developer needs to debug). The on-screen version is
|
|
# a compacted view of the same exception (shortened paths, collapsed
|
|
# library frames); see omnigent/crash_ui.render_crash_screen.
|
|
formatted = traceback.format_exception(type(exc), exc, tb or exc.__traceback__)
|
|
tb_text = "".join(formatted)
|
|
|
|
report_md = _build_report(exc, tb_text, source=source)
|
|
report_path = _save_report(report_md)
|
|
|
|
render_crash_screen(
|
|
app_name=_CONFIG["app_name"],
|
|
report_path=str(report_path),
|
|
exc=exc,
|
|
tb=tb or exc.__traceback__,
|
|
stream=stream,
|
|
first_party_prefixes=_CONFIG.get("first_party_prefixes", ("omnigent",)),
|
|
)
|
|
|
|
if interactive is None:
|
|
interactive = _is_interactive(stream)
|
|
if interactive:
|
|
_interactive_flow(report_md, report_path, exc, tb_text, stream)
|
|
else:
|
|
_fallback_notice(report_path, exc, tb_text, stream)
|
|
except Exception: # noqa: BLE001 — crash handler must never crash visibly
|
|
# Never let the crash handler itself crash visibly.
|
|
with contextlib.suppress(Exception):
|
|
_ORIG_EXCEPTHOOK(type(exc), exc, tb or exc.__traceback__)
|
|
finally:
|
|
_HANDLING.on = False
|
|
|
|
|
|
def _is_interactive(stream: TextIO) -> bool:
|
|
"""Interactive only when BOTH stderr and stdin are real TTYs.
|
|
|
|
This avoids hanging in scripts/CI where stderr happens to be a TTY
|
|
but stdin is closed or piped.
|
|
"""
|
|
stderr_tty = bool(getattr(stream, "isatty", lambda: False)())
|
|
stdin_tty = bool(getattr(sys.stdin, "isatty", lambda: False)())
|
|
return stderr_tty and stdin_tty
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Report building + persistence
|
|
# --------------------------------------------------------------------------- #
|
|
# Light redaction of common bearer/token shapes that could appear on
|
|
# the command line (e.g. ``--api-key sk-...``). The user reviews the
|
|
# report before posting; this just catches the obvious ones.
|
|
_TOKEN_PATTERNS = [
|
|
re.compile(r"sk-[A-Za-z0-9_\-]{6,}"), # OpenAI-style
|
|
re.compile(r"dapi-[A-Za-z0-9_\-]{6,}"), # Anthropic-style
|
|
re.compile(r"xox[baprs]-[A-Za-z0-9\-]{6,}"), # Slack
|
|
re.compile(r"gh[opusr]_[A-Za-z0-9]{16,}"), # GitHub
|
|
re.compile(r"AIza[A-Za-z0-9_\-]{20,}"), # Google API
|
|
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{6,}"), # Authorization header
|
|
]
|
|
|
|
|
|
def _redact(text: str) -> str:
|
|
for pat in _TOKEN_PATTERNS:
|
|
text = pat.sub(lambda m: m.group(0)[:4] + "***", text)
|
|
return text
|
|
|
|
|
|
def _command_line() -> str:
|
|
try:
|
|
return _redact(" ".join(sys.argv))
|
|
except Exception: # noqa: BLE001 pragma: no cover
|
|
return "(unavailable)"
|
|
|
|
|
|
def _build_report(exc: BaseException, tb_text: str, *, source: str) -> str:
|
|
"""Compose the GitHub-ready markdown report."""
|
|
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
exc_type = type(exc).__qualname__
|
|
msg = str(exc).strip() or "(no message)"
|
|
version = _CONFIG["version"]
|
|
app_name = _CONFIG["app_name"]
|
|
repo = _CONFIG["repo"]
|
|
|
|
return f"""# Crash Report — {app_name}
|
|
|
|
**Date:** {now}
|
|
**Version:** {app_name} {version}
|
|
**Platform:** {platform.platform()}
|
|
**Python:** {platform.python_version()}
|
|
**Source:** {source}
|
|
**Repository:** https://github.com/{repo}
|
|
|
|
## Summary
|
|
|
|
`{exc_type}`: {msg}
|
|
|
|
## Traceback
|
|
|
|
```
|
|
{tb_text}
|
|
```
|
|
|
|
## Command
|
|
|
|
```
|
|
{_command_line()}
|
|
```
|
|
|
|
---
|
|
|
|
> The full traceback is also pre-filled into the GitHub issue's
|
|
> Description field when filing from the crash prompt. Review the
|
|
> **Command** line above for secrets before submitting.
|
|
"""
|
|
|
|
|
|
def _save_report(report_md: str) -> Path:
|
|
"""Write the report to the crashes dir (rotated) and return its path."""
|
|
d = _crashes_dir()
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
path = d / f"crash-{stamp}.md"
|
|
if path.exists():
|
|
# Same-second collision. The pid separates concurrent processes, but a
|
|
# process that crashes twice in one second reuses its own pid — so keep
|
|
# counting until the name is free, else the second report would
|
|
# overwrite the first and the earlier crash would be lost.
|
|
base = f"crash-{stamp}-{os.getpid()}"
|
|
path = d / f"{base}.md"
|
|
suffix = 1
|
|
while path.exists():
|
|
path = d / f"{base}-{suffix}.md"
|
|
suffix += 1
|
|
path.write_text(report_md, encoding="utf-8")
|
|
with contextlib.suppress(OSError):
|
|
os.chmod(path, 0o600)
|
|
_rotate(d, _CONFIG.get("keep_reports", 10))
|
|
return path
|
|
|
|
|
|
def _rotate(d: Path, keep: int) -> None:
|
|
"""Keep only the newest ``keep`` ``crash-*.md`` reports."""
|
|
with contextlib.suppress(Exception):
|
|
files = sorted(
|
|
d.glob("crash-*.md"),
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
for old in files[keep:]:
|
|
old.unlink()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Interactive bug-filing flow (TTY only)
|
|
# --------------------------------------------------------------------------- #
|
|
def _interactive_flow(
|
|
report_md: str, report_path: Path, exc: BaseException, tb_text: str, stream: TextIO
|
|
) -> None:
|
|
url, body_included = _issue_url(exc, _issue_body(exc, tb_text))
|
|
if _prompt_yes_no(
|
|
"Help us fix it — file a GitHub issue with this report? [Y/n] ",
|
|
stream,
|
|
):
|
|
_copy_to_clipboard(report_md) # always: backup, or primary if body dropped
|
|
opened = _open_browser(url)
|
|
_print(stream, "")
|
|
if opened and body_included:
|
|
_print(stream, " ✓ Opening a pre-filled GitHub issue — review and submit.")
|
|
elif opened:
|
|
_print(
|
|
stream,
|
|
" ✓ Opening a GitHub issue — paste the report from your clipboard (Ctrl+V).",
|
|
)
|
|
else:
|
|
_print(stream, " Couldn't open the browser. Open this link to file the issue:")
|
|
_print(stream, f" {url}")
|
|
_print(stream, " The report is in your clipboard (Ctrl+V to paste).")
|
|
else:
|
|
_print(stream, "")
|
|
_print(stream, " Report saved here:")
|
|
_print(stream, f" {report_path}")
|
|
_print(stream, "")
|
|
|
|
|
|
def _fallback_notice(report_path: Path, exc: BaseException, tb_text: str, stream: TextIO) -> None:
|
|
"""Non-interactive: just state where the report is and where to file."""
|
|
url, _ = _issue_url(exc, _issue_body(exc, tb_text))
|
|
_print(stream, "")
|
|
_print(stream, f"A crash report was saved to: {report_path}")
|
|
_print(stream, f"File an issue: {url}")
|
|
_print(stream, "")
|
|
|
|
|
|
def _issue_body(exc: BaseException, tb_text: str) -> str:
|
|
"""Build the markdown for the issue template's Description field.
|
|
|
|
Leaner than :func:`_build_report`: the version and OS live in their
|
|
own prefilled template fields, so they're omitted here to avoid
|
|
duplication. What remains is the exception summary, the command
|
|
that triggered the crash, and the **full** traceback (every frame,
|
|
real paths) — exactly what a developer needs to reproduce and fix it.
|
|
"""
|
|
exc_type = type(exc).__qualname__
|
|
msg = str(exc).strip() or "(no message)"
|
|
return (
|
|
"This crash was auto-reported by Omnigent's crash handler.\n\n"
|
|
f"**Exception:** `{exc_type}: {msg}`\n\n"
|
|
"**Command:**\n"
|
|
f"```\n{_command_line()}\n```\n\n"
|
|
"**Traceback:**\n"
|
|
f"```\n{tb_text}```\n"
|
|
)
|
|
|
|
|
|
def _issue_url(exc: BaseException, body: str = "") -> tuple[str, bool]:
|
|
"""Pre-filled ``bug_report.yml`` issue URL.
|
|
|
|
Returns ``(url, body_included)``: the URL has the template, title,
|
|
version, and OS prefilled always; the Description (full traceback)
|
|
is included only if it fits :data:`_MAX_URL_LENGTH`. When the body
|
|
is dropped (``body_included is False``), the caller should tell the
|
|
user to paste the report from the clipboard instead.
|
|
|
|
Field IDs (``version``, ``os``, ``description``) come from
|
|
``.github/ISSUE_TEMPLATE/bug_report.yml``; keep in sync if it changes.
|
|
"""
|
|
first_line = (str(exc).strip().splitlines() or [""])[0]
|
|
title = f"[Crash] {type(exc).__qualname__}: {first_line[:140]}"
|
|
repo = _CONFIG["repo"]
|
|
version = _CONFIG.get("version", "unknown")
|
|
os_str = platform.platform(terse=True)
|
|
base = f"https://github.com/{repo}/issues/new?"
|
|
|
|
params = {
|
|
"template": "bug_report.yml",
|
|
"title": title,
|
|
"version": version,
|
|
"os": os_str,
|
|
}
|
|
if not body:
|
|
return base + urllib.parse.urlencode(params), False
|
|
|
|
# Try the full body in the URL. If it would exceed the safe limit,
|
|
# drop the description entirely — the clipboard carries the full
|
|
# report and the caller tells the user to paste it. No half-measures.
|
|
params["description"] = body
|
|
url = base + urllib.parse.urlencode(params)
|
|
if len(url) <= _MAX_URL_LENGTH:
|
|
return url, True
|
|
params.pop("description")
|
|
return base + urllib.parse.urlencode(params), False
|
|
|
|
|
|
def _prompt_yes_no(prompt: str, stream: TextIO) -> bool:
|
|
"""Ask a yes/no question; default Yes on empty Enter. Never raises."""
|
|
try:
|
|
stream.write(prompt)
|
|
stream.flush()
|
|
line = sys.stdin.readline().strip().lower()
|
|
except Exception: # noqa: BLE001 pragma: no cover (EOF / closed stdin)
|
|
return False
|
|
return line in ("", "y", "yes")
|
|
|
|
|
|
def _print(stream: TextIO, text: str) -> None:
|
|
stream.write(text + "\n")
|
|
stream.flush()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Clipboard + browser (all best-effort, silent on failure)
|
|
# --------------------------------------------------------------------------- #
|
|
def _copy_to_clipboard(text: str) -> bool:
|
|
"""Copy ``text`` to the system clipboard via the native tool. No deps."""
|
|
try:
|
|
if sys.platform == "darwin":
|
|
return _pipe(["pbcopy"], text)
|
|
if sys.platform.startswith("win"):
|
|
# clip.exe mangles some UTF-8 but is always present; good
|
|
# enough for tracebacks (mostly ASCII).
|
|
return _pipe(["clip"], text)
|
|
# Linux / *nix: prefer xclip, fall back to xsel.
|
|
for cmd in (("xclip", "-selection", "clipboard"), ("xsel", "--clipboard", "--input")):
|
|
if shutil.which(cmd[0]):
|
|
return _pipe(list(cmd), text)
|
|
return False
|
|
except Exception: # noqa: BLE001 pragma: no cover
|
|
return False
|
|
|
|
|
|
def _pipe(cmd: list[str], text: str) -> bool:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
input=text.encode("utf-8", "replace"),
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=5,
|
|
)
|
|
return proc.returncode == 0
|
|
|
|
|
|
def _open_browser(url: str) -> bool:
|
|
try:
|
|
return bool(webbrowser.open(url, new=2))
|
|
except Exception: # noqa: BLE001 pragma: no cover
|
|
return False
|