Input & UX

Writing Crash Reports Users Can Send

Turn unexpected exceptions into a short message plus a redacted crash report file with traceback, versions and context — so bug reports arrive complete without exposing secrets.

Updated

When a CLI hits a bug, two people need different things. The user needs a calm, short message and a way to get help — not forty lines of traceback in the middle of their work. The maintainer needs everything: the full traceback, the exact version, the Python version and platform, which command ran with which options, and the state that led there. Bug reports usually satisfy neither: a screenshot of half a traceback, "it doesn't work", no version. A crash report bridges the gap. On an unexpected exception, the CLI writes a complete, redacted report to a file, prints one short message with the file's path and where to send it, and exits with a distinct code. This guide builds that handler, decides what goes in the report and what must never, and tests it. It belongs to the error handling topic; expected errors — the ones with friendly messages — are covered in friendly error messages and tracebacks.

Prerequisites

Expected errors and bugs

What kind of failure is it? How a command line tool handles expected errors, interrupts and unexpected exceptions differently at the entry point. What kind of failure is it? What reached the top-level handler? MytoolError (expected) Message one line, documented exit code KeyboardInterrupt Exit 130 no report, no traceback Anything else (a bug) Report file + 3 lines, exit 70 Only bugs produce reports — so every report is worth reading.

The handler only deals with the unexpected. A missing file, a rejected token or an unreachable API are expected failures: they get a one-line message and a documented exit code, and no report. A KeyError deep in your code, an AttributeError from an API response shaped differently than you assumed — those are bugs, and bugs get a report. Keeping the two separate is what makes reports worth reading: every report is a defect to fix.

The recipe

# src/mytool/crash.py
from __future__ import annotations

import os
import platform
import re
import sys
import traceback
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path

from platformdirs import user_state_path

EX_SOFTWARE = 70
SECRET_FLAGS = {"--token", "--password", "--api-key", "--secret"}
SECRET_TEXT = re.compile(
    r"(?i)(token|password|secret|api[_-]?key|authorization)(\s*[=:]\s*)(?:bearer\s+|basic\s+)?\S+")
SAFE_ENV = ("LANG", "TERM", "CI", "PYTHONUTF8", "MYTOOL_PROFILE")


def _redact_argv(argv: list[str]) -> list[str]:
    out, hide_next = [], False
    for arg in argv:
        if hide_next:
            out.append("***")
            hide_next = False
        elif arg in SECRET_FLAGS:
            out.append(arg)
            hide_next = True
        elif "=" in arg and arg.split("=", 1)[0] in SECRET_FLAGS:
            out.append(arg.split("=", 1)[0] + "=***")
        else:
            out.append(arg)
    return out


def _redact_text(text: str) -> str:
    text = SECRET_TEXT.sub(r"\1\2***", text)
    return text.replace(str(Path.home()), "~")


def build_report(exc: BaseException, argv: list[str]) -> str:
    try:
        tool_version = version("mytool")
    except PackageNotFoundError:
        tool_version = "unknown (source checkout)"
    lines = [
        "# mytool crash report",
        f"time: {datetime.now(timezone.utc).isoformat(timespec='seconds')}",
        f"mytool: {tool_version}",
        f"python: {platform.python_version()} ({sys.executable})",
        f"platform: {platform.platform()}",
        f"command: mytool {' '.join(_redact_argv(argv))}",
        "environment: " + ", ".join(f"{k}={os.environ[k]}" for k in SAFE_ENV if k in os.environ),
        "",
        "".join(traceback.format_exception(exc)),
    ]
    return _redact_text("\n".join(lines))


def write_report(exc: BaseException, argv: list[str], directory: Path | None = None) -> Path:
    directory = directory or user_state_path("mytool") / "crashes"
    directory.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    path = directory / f"crash-{stamp}-{os.getpid()}.txt"
    path.write_text(build_report(exc, argv), encoding="utf-8")
    path.chmod(0o600)
    return path

The report is plain text with a fixed header — readable by people, easy to paste into an issue, simple to grep across many reports. Three redaction layers protect users. Arguments following known secret flags are replaced, in both --token X and --token=X forms. Free text is scrubbed for token=…, password: …, authorization=Bearer … and similar patterns, which catches secrets that ended up in exception messages — the optional Bearer/Basic group matters, because without it only the scheme word is replaced and the token itself survives, which is exactly what the first version of the test below caught. The home directory is shortened to ~, which keeps usernames out of paths. Environment variables are allow-listed, never dumped: only the handful that help debugging are included. The file is created with owner-only permissions.

Wiring it into the entry point

# src/mytool/main.py
import os
import sys

from mytool.cli import app
from mytool.crash import EX_SOFTWARE, write_report
from mytool.errors import MytoolError                      # expected, user-facing errors

ISSUES = "https://github.com/acme/mytool/issues/new"


def main() -> None:
    try:
        app(standalone_mode=False)
    except MytoolError as exc:
        print(f"error: {exc}", file=sys.stderr)
        raise SystemExit(exc.exit_code) from None
    except KeyboardInterrupt:
        raise SystemExit(130) from None
    except Exception as exc:                                 # a bug
        if os.environ.get("MYTOOL_DEBUG"):
            raise                                            # full traceback for developers
        path = write_report(exc, sys.argv[1:])
        print(f"mytool hit an unexpected error: {type(exc).__name__}.\n"
              f"A crash report was saved to {path}\n"
              f"Please attach it to a bug report: {ISSUES}", file=sys.stderr)
        raise SystemExit(EX_SOFTWARE) from None

Running Typer or Click with standalone_mode=False lets exceptions reach your handler instead of being printed by the framework; usage errors still need handling — catch the framework's usage-error type and print its message, or keep standalone mode and install the crash handler with sys.excepthook. MYTOOL_DEBUG=1 skips the report and shows the raw traceback, which is what you want while developing and what you ask users to set when a report is not enough. Exit code 70 (EX_SOFTWARE) distinguishes "the tool has a bug" from usage errors and expected failures, as in choosing exit codes for CLI tools.

What the user sees Terminal session where a command hits an unexpected error and prints a short message with the crash report path, then the same run with debug mode showing the traceback. What the user sees bash $ mytool deploy --token s3cr3t mytool hit an unexpected error: KeyError. A crash report was saved to ~/.local/state/mytool/crashes/crash-20261002T174845Z-3509.txt Please attach it to a bug report: https://github.com/acme/mytool/issues/new $ MYTOOL_DEBUG=1 mytool deploy --token s3cr3t Traceback (most recent call last): … Exit code 70 marks a bug, distinct from usage errors and expected failures.

Adding your own context

The generic header covers the environment; the most useful extra lines are specific to your tool — the active profile, the API endpoint, whether a config file was found, the last operation started. Collect them in a small registry that commands update as they go, and append it to the report:

CONTEXT: dict[str, str] = {}


def note(key: str, value: object) -> None:
    """Remember a fact that helps explain a crash (never a secret)."""
    CONTEXT[key] = str(value)

# in a command:
#   note("profile", profile.name); note("endpoint", api_url); note("step", "uploading manifests")

In build_report, add *(f"{k}: {v}" for k, v in sorted(CONTEXT.items())) after the environment line. "step: uploading manifests" often explains a traceback faster than the traceback itself, and because the values pass through the same _redact_text, an accidental secret is still scrubbed — though the rule stays: never note() credentials in the first place.

UX considerations

  • Two or three lines, no traceback. What happened in one phrase, where the report is, where to send it.
  • Make the path copyable. An absolute path, on its own line, works with every terminal's selection.
  • Pre-fill the issue. A link with a title and template (?template=crash.md) gets better reports than a bare URL.
  • Let users review before sending. Plain text, written to disk, never uploaded automatically — users can read and edit it, which is what makes them comfortable sending it. Automatic upload belongs in the same opt-in category as opt-in usage telemetry.
  • Clean up old reports. Keep the last ten or so; a scheduled job that crashes every hour should not fill the disk.
What goes into a crash report Information a command line tool crash report should include and information it must never contain. What goes into a crash report Include ✓ Tool, Python and platform versions ✓ Command line with secrets masked ✓ Allow-listed environment variables ✓ Full traceback and tool context notes Never ✗ Tokens, passwords, API keys ✗ The whole environment ✗ Local variables by default ✗ Automatic upload without consent Plain text on disk lets users read the report before they send it.

Testing the behaviour

The redaction is the part to test most carefully, with the secret shapes your users actually produce:

# tests/test_crash.py
import stat
from pathlib import Path

from mytool.crash import _redact_argv, build_report, write_report


def boom():
    token = "tok_live_123"
    raise ValueError(f"API rejected request: authorization=Bearer {token}")


def test_argv_secrets_are_hidden():
    argv = ["deploy", "--token", "s3cr3t", "--api-key=abc", "--region", "eu"]
    assert _redact_argv(argv) == ["deploy", "--token", "***", "--api-key=***", "--region", "eu"]


def test_report_has_context_but_no_secrets():
    try:
        boom()
    except ValueError as exc:
        report = build_report(exc, ["deploy", "--token", "s3cr3t"])
    assert "mytool crash report" in report and "python:" in report
    assert "ValueError" in report and "boom" in report          # traceback included
    assert "s3cr3t" not in report and "tok_live_123" not in report
    assert str(Path.home()) not in report


def test_report_file_is_private(tmp_path):
    try:
        boom()
    except ValueError as exc:
        path = write_report(exc, [], directory=tmp_path)
    assert path.exists()
    assert stat.S_IMODE(path.stat().st_mode) == 0o600

Note what the second test proves: the secret inside the exception message — the most common leak — is removed, along with the one in the arguments. On Windows, chmod(0o600) only toggles the read-only bit, so skip the permission assertion there.

Conclusion

Unexpected exceptions deserve better than a raw traceback. Catch them at the entry point after expected errors, write a plain-text report with version, platform, redacted command line, allow-listed environment and the full traceback into the user's state directory with private permissions, print three lines pointing at the file and the issue tracker, and exit with code 70. Offer MYTOOL_DEBUG=1 for raw tracebacks, never upload automatically, and test the redaction with real secret shapes.

Frequently asked questions

Should the report include local variables?

They are the most useful debugging data and the most likely to contain secrets — tokens, file contents, personal data. Leave them out by default. If you need them, include them only under MYTOOL_DEBUG=2 and say so clearly.

What about crashes inside a Textual or Rich live display?

Stop the display before writing the message, or the terminal may be left in an odd state. Textual apps restore the terminal on exit; catch the exception outside app.run() and handle it there.

Can I use sys.excepthook instead of try/except?

Yes — sys.excepthook runs for any uncaught exception and avoids changing how the framework runs. A try/except in main() is easier to test and makes the control flow explicit; both work.