diff --git a/openadapt/cli.py b/openadapt/cli.py index cfc69dbb9..2232edecf 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -38,7 +38,11 @@ def main(): openadapt train --capture my-task # Train a model openadapt eval --checkpoint model.pt # Evaluate the model """ - pass + # Initialize crash reporting before dispatching to any subcommand. Lazy + # import keeps CLI startup fast and import-safe; the call never raises. + from openadapt.error_reporting import configure_error_reporting + + configure_error_reporting() # ============================================================================= diff --git a/openadapt/config.py b/openadapt/config.py index 3580e8f28..69841bcfa 100644 --- a/openadapt/config.py +++ b/openadapt/config.py @@ -49,6 +49,20 @@ class OpenAdaptSettings(BaseSettings): google_api_key: Optional[str] = None lambda_api_key: Optional[str] = None + # ========================================================================= + # Crash reporting (see openadapt/error_reporting.py) + # ========================================================================= + # Master switch (OPENADAPT_ERROR_REPORTING_ENABLED). + error_reporting_enabled: bool = True + # Report from a source run too (default: only packaged/frozen builds report). + error_reporting_force: bool = False + # Sentry-compatible DSN; defaults to the GlitchTip openadaptai/openadapt (3798) + # project. The DSN public key is an ingestion key, not a secret. + error_reporting_dsn: Optional[str] = ( + "https://dcf5d7889a3b4b47ae12a3af9ffcbeb7@app.glitchtip.com/3798" + ) + error_reporting_environment: str = "production" + # ========================================================================= # Paths # ========================================================================= diff --git a/openadapt/error_reporting.py b/openadapt/error_reporting.py new file mode 100644 index 000000000..02e94e5e4 --- /dev/null +++ b/openadapt/error_reporting.py @@ -0,0 +1,95 @@ +"""Optional crash reporting for the OpenAdapt CLI. + +Reports unhandled errors to a Sentry-compatible backend (GlitchTip). Designed to +be safe and quiet: + +- **Off unless it's a packaged build.** By default reporting is active only in a + frozen/packaged build (the released app). Source / dev / CI / headless runs do + NOT report -- that source-run flooding is what previously buried real issues. + Set ``OPENADAPT_ERROR_REPORTING_FORCE=1`` to report from a source run (e.g. to + test it). This replaces the old, fragile ``active_branch == "main"`` gate. +- **Master switch:** ``OPENADAPT_ERROR_REPORTING_ENABLED=0`` disables entirely. +- **Filtered:** unactionable unsupported-environment errors (no display, wrong + platform, broken native deps) are dropped in ``before_send``. +- **Never raises:** if ``sentry-sdk`` is missing or init fails, it is a silent + no-op -- telemetry must never break the app. + +Config lives on ``openadapt.config.settings`` (``OPENADAPT_ERROR_REPORTING_*``). +""" + +from __future__ import annotations + +import sys +from typing import Optional + +from openadapt.config import settings +from openadapt.version import __version__ + +try: + import sentry_sdk +except ImportError: # optional dependency -> reporting becomes a no-op + sentry_sdk = None # type: ignore[assignment] + + +# Unactionable: errors from environments where OpenAdapt cannot run -- headless / +# no display, wrong platform, or a broken/partial native dependency set. These +# flood the dashboard from CI and automated/headless environments and bury real +# issues. Drop them. +_UNSUPPORTED_ENV_SUBSTRINGS = ( + "failed to acquire X connection", + "Bad display name", + "DISPLAY not set", + "this platform is not supported", + "No module named 'pywinauto'", + "module 'pywinauto' has no attribute", + "_ARRAY_API not found", +) + + +def is_unsupported_environment_error(event: dict, hint: dict) -> bool: + """Return True if the event is an unactionable unsupported-environment error.""" + texts = [] + exc_info = hint.get("exc_info") if hint else None + if exc_info and exc_info[1] is not None: + texts.append(f"{type(exc_info[1]).__name__}: {exc_info[1]}") + for value in (event.get("exception", {}) or {}).get("values", []) or []: + texts.append(f"{value.get('type')}: {value.get('value')}") + blob = " ".join(texts) + return any(sub in blob for sub in _UNSUPPORTED_ENV_SUBSTRINGS) + + +def before_send_event(event: dict, hint: dict) -> Optional[dict]: + """Drop unactionable unsupported-environment events; keep the rest.""" + if is_unsupported_environment_error(event, hint): + return None + return event + + +def _reporting_enabled() -> bool: + """Report only from a packaged build by default (never source/dev/CI/headless).""" + if not settings.error_reporting_enabled: + return False + if getattr(sys, "frozen", False): + return True + return settings.error_reporting_force + + +def configure_error_reporting() -> None: + """Initialize crash reporting if enabled. Safe to call always; never raises.""" + if ( + sentry_sdk is None + or not _reporting_enabled() + or not settings.error_reporting_dsn + ): + return + try: + sentry_sdk.init( + dsn=settings.error_reporting_dsn, + release=f"openadapt@{__version__}", + environment=settings.error_reporting_environment, + before_send=before_send_event, + ignore_errors=[KeyboardInterrupt], + ) + except Exception: + # Telemetry must never break the app. + pass diff --git a/pyproject.toml b/pyproject.toml index aad41fe52..48970d83a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,12 @@ classifiers = [ # Minimal base - just the CLI dependencies = [ "click>=8.0.0", + # Backs openadapt.config (BaseSettings) -- was a latent missing base dep: + # a clean install could not `import openadapt.config` without it. + "pydantic-settings>=2.0.0", + # Optional crash reporting (see openadapt/error_reporting.py). Lightweight, + # pure-Python; reporting is off unless running a packaged build. + "sentry-sdk>=2.0.0", ] [project.optional-dependencies] diff --git a/tests/test_error_reporting.py b/tests/test_error_reporting.py new file mode 100644 index 000000000..228ba2e92 --- /dev/null +++ b/tests/test_error_reporting.py @@ -0,0 +1,65 @@ +"""Tests for openadapt.error_reporting: filtering + gating (no network).""" + +from openadapt import error_reporting as er +from openadapt.config import settings + + +def _hint(exc: Exception) -> dict: + return {"exc_info": (type(exc), exc, None)} + + +def test_drops_unsupported_environment_errors() -> None: + for msg in [ + "this platform is not supported: failed to acquire X connection", + "ScreenShotError: $DISPLAY not set.", + "No module named 'pywinauto'", + "module 'pywinauto' has no attribute 'application'", + "_ARRAY_API not found", + ]: + assert er.before_send_event({}, _hint(RuntimeError(msg))) is None + + +def test_keeps_real_errors() -> None: + event = {"id": "real"} + result = er.before_send_event(event, _hint(ValueError("real extraction bug"))) + assert result is event + + +def test_filter_reads_event_exception_values_too() -> None: + event = { + "exception": { + "values": [{"type": "ImportError", "value": "No module named 'pywinauto'"}] + } + } + assert er.before_send_event(event, {}) is None + + +def test_disabled_by_default_from_source(monkeypatch) -> None: + monkeypatch.setattr(er.sys, "frozen", False, raising=False) + monkeypatch.setattr(settings, "error_reporting_enabled", True) + monkeypatch.setattr(settings, "error_reporting_force", False) + assert er._reporting_enabled() is False + + +def test_enabled_when_frozen(monkeypatch) -> None: + monkeypatch.setattr(er.sys, "frozen", True, raising=False) + monkeypatch.setattr(settings, "error_reporting_enabled", True) + assert er._reporting_enabled() is True + + +def test_master_switch_off(monkeypatch) -> None: + monkeypatch.setattr(er.sys, "frozen", True, raising=False) + monkeypatch.setattr(settings, "error_reporting_enabled", False) + assert er._reporting_enabled() is False + + +def test_force_enables_from_source(monkeypatch) -> None: + monkeypatch.setattr(er.sys, "frozen", False, raising=False) + monkeypatch.setattr(settings, "error_reporting_enabled", True) + monkeypatch.setattr(settings, "error_reporting_force", True) + assert er._reporting_enabled() is True + + +def test_configure_never_raises_when_disabled(monkeypatch) -> None: + monkeypatch.setattr(settings, "error_reporting_enabled", False) + er.configure_error_reporting() # no-op, must not raise