-
Notifications
You must be signed in to change notification settings - Fork 19
Add Bugsnag instrumentation module #230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
morgan-wowk
wants to merge
1
commit into
bugsnag/add-dependency
Choose a base branch
from
bugsnag/instrumentation-module
base: bugsnag/add-dependency
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+285
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
96 changes: 96 additions & 0 deletions
96
cloud_pipelines_backend/instrumentation/bugsnag_instrumentation.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", "") | ||
|
|
||
| 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") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
instead of just
This does not really matter, but I just sense that maybe there is some misconception regarding how
os.environ.getworks (returns None if key does not exists).