diff --git a/cloud_pipelines_backend/instrumentation/bugsnag_instrumentation.py b/cloud_pipelines_backend/instrumentation/bugsnag_instrumentation.py new file mode 100644 index 0000000..e3a877b --- /dev/null +++ b/cloud_pipelines_backend/instrumentation/bugsnag_instrumentation.py @@ -0,0 +1,119 @@ +""" +Bugsnag error reporting integration. + +Provides entry points to configure Bugsnag and report exceptions. + +No-op if TANGLE_BUGSNAG_API_KEY or TANGLE_ENV are not set. + +Environment variables: + TANGLE_BUGSNAG_API_KEY Required to enable Bugsnag reporting. + TANGLE_ENV Release stage (e.g. "staging", "production"). + TANGLE_SERVICE_VERSION App version tag (e.g. git SHA). Optional. + TANGLE_BUGSNAG_NOTIFY_ENDPOINT Custom notify URL. Optional. + TANGLE_BUGSNAG_SESSIONS_ENDPOINT Custom sessions URL. Optional. +""" + +import logging +import os +from collections.abc import Callable +from typing import Any, NotRequired, TypedDict + +import bugsnag as bugsnag_sdk +import bugsnag.event as bugsnag_event + +from cloud_pipelines_backend.instrumentation import contextual_logging + +_logger = logging.getLogger(__name__) + +_BUGSNAG_API_KEY = os.environ.get("TANGLE_BUGSNAG_API_KEY", "") +_TANGLE_ENV = os.environ.get("TANGLE_ENV", "") +_SERVICE_VERSION = os.environ.get("TANGLE_SERVICE_VERSION", "") +_NOTIFY_ENDPOINT = os.environ.get("TANGLE_BUGSNAG_NOTIFY_ENDPOINT", "") +_SESSIONS_ENDPOINT = os.environ.get("TANGLE_BUGSNAG_SESSIONS_ENDPOINT", "") + +IS_BUGSNAG_ENABLED: bool = bool(_BUGSNAG_API_KEY and _TANGLE_ENV) + + +class _BugsnagConfig(TypedDict): + api_key: str + release_stage: str + before_notify: Callable[[bugsnag_event.Event], None] + auto_capture_sessions: bool + params_filters: list[str] + app_version: NotRequired[str] + endpoint: NotRequired[str] + sessions_endpoint: NotRequired[str] + project_root: NotRequired[str] + + +def _before_notify(event: bugsnag_event.Event) -> None: + """Attach contextual logging metadata to every Bugsnag event.""" + context = contextual_logging.get_all_context_metadata() + if context: + event.add_tab("tangle_context", context) + + +def setup(*, service_name: str | None = None) -> None: + """Configure the Bugsnag client. + + No-op if TANGLE_BUGSNAG_API_KEY or TANGLE_ENV are not set. + + Args: + service_name: Identifies the process in Bugsnag (e.g. "tangle-api"). + """ + if not IS_BUGSNAG_ENABLED: + return + + config: _BugsnagConfig = { + "api_key": _BUGSNAG_API_KEY, + "release_stage": _TANGLE_ENV, + "before_notify": _before_notify, + "auto_capture_sessions": True, + "params_filters": [ + "authorization", + "cookie", + "x-api-key", + "x-forwarded-for", + "proxy-authorization", + ], + } + + if _SERVICE_VERSION: + config["app_version"] = _SERVICE_VERSION + + if _NOTIFY_ENDPOINT: + config["endpoint"] = _NOTIFY_ENDPOINT + + if _SESSIONS_ENDPOINT: + config["sessions_endpoint"] = _SESSIONS_ENDPOINT + + if service_name: + config["project_root"] = service_name + + try: + bugsnag_sdk.configure(**config) + except Exception: + _logger.exception("Failed to initialize Bugsnag") + + +def notify(*, exception: BaseException, **metadata: Any) -> None: + """Report an exception to Bugsnag. + + No-op if Bugsnag is disabled. + + Args: + exception: The exception to report. + **metadata: Additional key/value pairs attached as a "extra" metadata tab. + """ + if not IS_BUGSNAG_ENABLED: + return + + extra_data = metadata or None + + try: + bugsnag_sdk.notify( + exception, + meta_data={"extra": extra_data} if extra_data else {}, + ) + except Exception: + _logger.exception("Failed to notify Bugsnag") diff --git a/tests/instrumentation/test_bugsnag.py b/tests/instrumentation/test_bugsnag.py new file mode 100644 index 0000000..e98af2f --- /dev/null +++ b/tests/instrumentation/test_bugsnag.py @@ -0,0 +1,189 @@ +"""Tests for the Bugsnag instrumentation module.""" + +import unittest.mock as mock + +import pytest + + +def test_is_bugsnag_enabled_false_when_no_env_vars(monkeypatch): + monkeypatch.delenv("TANGLE_BUGSNAG_API_KEY", raising=False) + monkeypatch.delenv("TANGLE_ENV", raising=False) + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + assert bugsnag_module.IS_BUGSNAG_ENABLED is False + + +def test_is_bugsnag_enabled_false_when_only_api_key(monkeypatch): + monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-key") + monkeypatch.delenv("TANGLE_ENV", raising=False) + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + assert bugsnag_module.IS_BUGSNAG_ENABLED is False + + +def test_is_bugsnag_enabled_false_when_only_env(monkeypatch): + monkeypatch.delenv("TANGLE_BUGSNAG_API_KEY", raising=False) + monkeypatch.setenv("TANGLE_ENV", "staging") + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + assert bugsnag_module.IS_BUGSNAG_ENABLED is False + + +def test_is_bugsnag_enabled_true_when_both_set(monkeypatch): + monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-key") + monkeypatch.setenv("TANGLE_ENV", "staging") + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + assert bugsnag_module.IS_BUGSNAG_ENABLED is True + + +def test_setup_noop_when_disabled(monkeypatch): + monkeypatch.delenv("TANGLE_BUGSNAG_API_KEY", raising=False) + monkeypatch.delenv("TANGLE_ENV", raising=False) + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + with mock.patch("bugsnag.configure") as mock_configure: + bugsnag_module.setup(service_name="test-service") + mock_configure.assert_not_called() + + +def test_setup_calls_bugsnag_configure_when_enabled(monkeypatch): + monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-api-key") + monkeypatch.setenv("TANGLE_ENV", "staging") + monkeypatch.delenv("TANGLE_SERVICE_VERSION", raising=False) + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + with mock.patch("bugsnag.configure") as mock_configure: + bugsnag_module.setup(service_name="tangle-api") + mock_configure.assert_called_once() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["api_key"] == "test-api-key" + assert call_kwargs["release_stage"] == "staging" + + +def test_setup_includes_app_version_when_set(monkeypatch): + monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-api-key") + monkeypatch.setenv("TANGLE_ENV", "production") + monkeypatch.setenv("TANGLE_SERVICE_VERSION", "abc123") + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + with mock.patch("bugsnag.configure") as mock_configure: + bugsnag_module.setup() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["app_version"] == "abc123" + + +def test_notify_noop_when_disabled(monkeypatch): + monkeypatch.delenv("TANGLE_BUGSNAG_API_KEY", raising=False) + monkeypatch.delenv("TANGLE_ENV", raising=False) + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + with mock.patch("bugsnag.notify") as mock_notify: + bugsnag_module.notify(exception=ValueError("test error")) + mock_notify.assert_not_called() + + +def test_notify_calls_bugsnag_when_enabled(monkeypatch): + monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-api-key") + monkeypatch.setenv("TANGLE_ENV", "staging") + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + exc = ValueError("something went wrong") + with mock.patch("bugsnag.notify") as mock_notify: + bugsnag_module.notify(exception=exc, execution_id="exec-123") + mock_notify.assert_called_once() + call_args = mock_notify.call_args + assert call_args.args[0] is exc + assert call_args.kwargs["meta_data"] == {"extra": {"execution_id": "exec-123"}} + + +def test_notify_handles_bugsnag_failure_gracefully(monkeypatch): + monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-api-key") + monkeypatch.setenv("TANGLE_ENV", "staging") + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + with mock.patch("bugsnag.notify", side_effect=RuntimeError("network error")): + # Should not raise + bugsnag_module.notify(exception=ValueError("original error")) + + +def test_before_notify_attaches_context_metadata(monkeypatch): + monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-api-key") + monkeypatch.setenv("TANGLE_ENV", "staging") + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + from cloud_pipelines_backend.instrumentation import contextual_logging + + mock_event = mock.MagicMock() + + with contextual_logging.logging_context( + request_id="req-abc", user_id="user@example.com" + ): + bugsnag_module._before_notify(mock_event) + + mock_event.add_tab.assert_called_once_with( + "tangle_context", + {"request_id": "req-abc", "user_id": "user@example.com"}, + ) + + +def test_before_notify_skips_empty_context(monkeypatch): + monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-api-key") + monkeypatch.setenv("TANGLE_ENV", "staging") + + import importlib + import cloud_pipelines_backend.instrumentation.bugsnag_instrumentation as bugsnag_module + + importlib.reload(bugsnag_module) + + from cloud_pipelines_backend.instrumentation import contextual_logging + + contextual_logging.clear_context_metadata() + + mock_event = mock.MagicMock() + bugsnag_module._before_notify(mock_event) + mock_event.add_tab.assert_not_called()