Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion openadapt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


# =============================================================================
Expand Down
14 changes: 14 additions & 0 deletions openadapt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =========================================================================
Expand Down
95 changes: 95 additions & 0 deletions openadapt/error_reporting.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
65 changes: 65 additions & 0 deletions tests/test_error_reporting.py
Original file line number Diff line number Diff line change
@@ -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
Loading