From e6a36dc1147aee7b36dab187947071e460231648 Mon Sep 17 00:00:00 2001 From: Morgan Wowk Date: Thu, 28 May 2026 15:38:32 -0700 Subject: [PATCH] bugsnag: emit slice_name from service_name in custom metadata tab Observe error analytics filters on custom.slice_name to assign errors to a slice. Store the service_name passed to setup() and write it as custom.slice_name on every event so errors from orchestrator, api, and metrics_poller each land in their own slice. Consolidates all custom tab writes into a single add_tab call. --- .../bugsnag_instrumentation.py | 19 ++++++- tests/instrumentation/test_bugsnag.py | 55 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/cloud_pipelines_backend/instrumentation/bugsnag_instrumentation.py b/cloud_pipelines_backend/instrumentation/bugsnag_instrumentation.py index c8dec97..295a2a3 100644 --- a/cloud_pipelines_backend/instrumentation/bugsnag_instrumentation.py +++ b/cloud_pipelines_backend/instrumentation/bugsnag_instrumentation.py @@ -35,6 +35,7 @@ IS_BUGSNAG_ENABLED: bool = bool(_BUGSNAG_API_KEY) _setup_called: bool = False +_service_name: str | None = None def _before_notify(event: bugsnag_event.Event) -> None: @@ -42,6 +43,11 @@ def _before_notify(event: bugsnag_event.Event) -> None: context = contextual_logging.get_all_context_metadata() if context: event.add_tab("tangle_context", context) + + custom: dict[str, str] = {} + if _service_name: + custom["slice_name"] = _service_name + if _CUSTOM_GROUPING_KEY and event.original_error: # Use the full chain for grouping so that "LauncherError <- TimeoutError" # and "LauncherError <- ApiException" land in separate, stable groups. @@ -50,7 +56,7 @@ def _before_notify(event: bugsnag_event.Event) -> None: ) prefix = (event.metadata.get("extra") or {}).get("grouping_prefix") key_value = f"{prefix}: {chain}" if prefix else chain - event.add_tab("custom", {_CUSTOM_GROUPING_KEY: key_value}) + custom[_CUSTOM_GROUPING_KEY] = key_value if prefix and event.errors: try: for error in event.errors: @@ -74,6 +80,9 @@ def _before_notify(event: bugsnag_event.Event) -> None: "Could not set chain title on errorClass", exc_info=True ) + if custom: + event.add_tab("custom", custom) + def setup(*, service_name: str | None = None) -> None: """Configure the Bugsnag client. @@ -81,7 +90,10 @@ def setup(*, service_name: str | None = None) -> None: No-op if TANGLE_BUGSNAG_API_KEY is not set. Args: - service_name: Identifies the process in Bugsnag (e.g. "tangle-api"). + service_name: Identifies the process in Bugsnag (e.g. "tangle-orchestrator-production"). + Also emitted as ``slice_name`` in the Bugsnag "custom" metadata tab on every event, + so errors can be filtered by service slice. Note: ``slice_name`` is a reserved key — + avoid setting ``TANGLE_BUGSNAG_CUSTOM_GROUPING_KEY=slice_name``. """ if not IS_BUGSNAG_ENABLED: return @@ -104,8 +116,9 @@ def setup(*, service_name: str | None = None) -> None: project_root=service_name, ) bugsnag_sdk.before_notify(_before_notify) - global _setup_called + global _setup_called, _service_name _setup_called = True + _service_name = service_name except Exception: _logger.exception("Failed to initialize Bugsnag") diff --git a/tests/instrumentation/test_bugsnag.py b/tests/instrumentation/test_bugsnag.py index d40fdd9..0b65a0d 100644 --- a/tests/instrumentation/test_bugsnag.py +++ b/tests/instrumentation/test_bugsnag.py @@ -271,6 +271,31 @@ def test_before_notify_skips_error_class_prefix_gracefully_on_bad_errors_structu bugsnag_module._before_notify(mock_event) +def test_before_notify_sets_slice_name_when_service_name_configured(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() + + with mock.patch("bugsnag.configure"), mock.patch("bugsnag.before_notify"): + bugsnag_module.setup(service_name="orchestrator") + + mock_event = mock.MagicMock() + mock_event.original_error = None + mock_event.metadata = {} + + bugsnag_module._before_notify(mock_event) + + mock_event.add_tab.assert_called_once_with("custom", {"slice_name": "orchestrator"}) + + def test_before_notify_skips_empty_context(monkeypatch): monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-api-key") monkeypatch.setenv("TANGLE_ENV", "staging") @@ -287,3 +312,33 @@ def test_before_notify_skips_empty_context(monkeypatch): mock_event = mock.MagicMock() bugsnag_module._before_notify(mock_event) mock_event.add_tab.assert_not_called() + + +def test_before_notify_omits_custom_tab_when_no_service_name_and_no_grouping_key( + monkeypatch, +): + monkeypatch.setenv("TANGLE_BUGSNAG_API_KEY", "test-api-key") + monkeypatch.delenv("TANGLE_BUGSNAG_CUSTOM_GROUPING_KEY", raising=False) + + 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() + + with mock.patch("bugsnag.configure"), mock.patch("bugsnag.before_notify"): + bugsnag_module.setup(service_name=None) + + mock_event = mock.MagicMock() + mock_event.original_error = None + mock_event.metadata = {} + bugsnag_module._before_notify(mock_event) + + custom_calls = [ + c for c in mock_event.add_tab.call_args_list if c.args[0] == "custom" + ] + assert custom_calls == []