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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""
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 typing import Any

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", "")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small Nit:
Why first turn missing env variables into empty strings and then into Nones?

Why

_VAR = os.environ.get("XXX", "")
...
param=_VAR or None

instead of just

_VAR = os.environ.get("XXX")
...
param = _VAR

This does not really matter, but I just sense that maybe there is some misconception regarding how os.environ.get works (returns None if key does not exists).


IS_BUGSNAG_ENABLED: bool = bool(_BUGSNAG_API_KEY and _TANGLE_ENV)


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

try:
bugsnag_sdk.configure(
api_key=_BUGSNAG_API_KEY,
release_stage=_TANGLE_ENV,
auto_capture_sessions=True,
params_filters=[
"authorization",
"cookie",
"x-api-key",
"x-forwarded-for",
"proxy-authorization",
],
app_version=_SERVICE_VERSION or None,
endpoint=_NOTIFY_ENDPOINT or None,
session_endpoint=_SESSIONS_ENDPOINT or None,
project_root=service_name,
)
bugsnag_sdk.before_notify(_before_notify)
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()