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
119 changes: 119 additions & 0 deletions cloud_pipelines_backend/instrumentation/bugsnag_instrumentation.py
Original file line number Diff line number Diff line change
@@ -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")
189 changes: 189 additions & 0 deletions tests/instrumentation/test_bugsnag.py
Original file line number Diff line number Diff line change
@@ -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()