Skip to content
Merged
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
29 changes: 26 additions & 3 deletions common/lib/error_tracking.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import os
import importlib
import logging
import os

import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration

Expand All @@ -8,6 +10,10 @@ def init_sentry():
sentry_sdk.init(
dsn=os.environ["SENTRY_DSN"],
environment=os.environ["ENV"],
# Tag every event with the build commit so a regression can be
# bisected to a specific deploy. Unset releases are fine — Sentry
# treats the field as optional.
release=os.environ.get("VCON_SERVER_GIT_COMMIT") or None,
integrations=[
LoggingIntegration(
level=logging.INFO, # Capture info and above as breadcrumbs
Expand All @@ -19,8 +25,25 @@ def init_sentry():


def init_error_tracker():
if os.environ.get("SENTRY_DSN"):
init_sentry()
if not os.environ.get("SENTRY_DSN"):
return
init_sentry()
# Optional downstream enrichment hook. Deployments that ship an
# ``error_tracking_ext`` module on the Python path can attach
# process-wide Sentry tags / context there (e.g. proprietary
# identifiers not appropriate for OSS). Imported opportunistically
# so OSS keeps zero compile-time dependency on it; an enrichment
# failure is caught so it never breaks startup.
try:
ext = importlib.import_module("error_tracking_ext")
except ImportError:
return
try:
ext.enrich()
except Exception:
logging.getLogger(__name__).exception(
"error_tracking_ext.enrich() raised; continuing without enrichment"
)


def capture_exception(e):
Expand Down
98 changes: 97 additions & 1 deletion common/tests/lib/test_error_tracking.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import sys
import types
from unittest.mock import patch

from lib.error_tracking import capture_exception, init_error_tracker
import pytest

from lib.error_tracking import capture_exception, init_error_tracker, init_sentry


@patch("lib.error_tracking.init_sentry")
Expand All @@ -21,6 +25,98 @@ def test_init_error_tracker_initializes_when_dsn_is_present(mock_init_sentry, mo
mock_init_sentry.assert_called_once()


@pytest.fixture
def _no_ext_module():
# Tests that exercise init_error_tracker shouldn't see whatever
# error_tracking_ext is on the host's path; clear it for the test.
saved = sys.modules.pop("error_tracking_ext", None)
try:
yield
finally:
if saved is not None:
sys.modules["error_tracking_ext"] = saved


@patch("lib.error_tracking.sentry_sdk.init")
def test_init_sentry_passes_release_from_git_commit_env(mock_init, monkeypatch):
# Sentry's release field is what lets a regression be tied back to a
# specific deploy. The Docker image already bakes in the commit SHA,
# so wire it through if present.
monkeypatch.setenv("SENTRY_DSN", "https://example@sentry.io/1")
monkeypatch.setenv("ENV", "test")
monkeypatch.setenv("VCON_SERVER_GIT_COMMIT", "abc1234")

init_sentry()

kwargs = mock_init.call_args.kwargs
assert kwargs["release"] == "abc1234"


@patch("lib.error_tracking.sentry_sdk.init")
def test_init_sentry_release_is_none_when_commit_env_missing(mock_init, monkeypatch):
# Release is optional; an unset commit shouldn't poison init with "".
monkeypatch.setenv("SENTRY_DSN", "https://example@sentry.io/1")
monkeypatch.setenv("ENV", "test")
monkeypatch.delenv("VCON_SERVER_GIT_COMMIT", raising=False)

init_sentry()

assert mock_init.call_args.kwargs["release"] is None


@patch("lib.error_tracking.init_sentry")
def test_init_error_tracker_invokes_extension_enrich_when_present(
mock_init_sentry, monkeypatch, _no_ext_module
):
# Deployments can ship an error_tracking_ext module to attach
# proprietary tags after OSS init. When present, its enrich()
# must be called once.
monkeypatch.setenv("SENTRY_DSN", "https://example@sentry.io/1")
fake_ext = types.ModuleType("error_tracking_ext")
enrich_calls = []
fake_ext.enrich = lambda: enrich_calls.append(1)
sys.modules["error_tracking_ext"] = fake_ext

init_error_tracker()

mock_init_sentry.assert_called_once()
assert enrich_calls == [1]


@patch("lib.error_tracking.init_sentry")
def test_init_error_tracker_tolerates_missing_extension_module(
mock_init_sentry, monkeypatch, _no_ext_module
):
# The extension hook is opportunistic — absence is the OSS default,
# not an error.
monkeypatch.setenv("SENTRY_DSN", "https://example@sentry.io/1")

init_error_tracker() # must not raise

mock_init_sentry.assert_called_once()


@patch("lib.error_tracking.init_sentry")
def test_init_error_tracker_swallows_extension_errors(
mock_init_sentry, monkeypatch, _no_ext_module, caplog
):
# A broken enrichment must never abort startup. Log and continue.
monkeypatch.setenv("SENTRY_DSN", "https://example@sentry.io/1")
fake_ext = types.ModuleType("error_tracking_ext")

def boom():
raise RuntimeError("broken extension")

fake_ext.enrich = boom
sys.modules["error_tracking_ext"] = fake_ext

with caplog.at_level("ERROR"):
init_error_tracker()

mock_init_sentry.assert_called_once()
assert any("error_tracking_ext.enrich() raised" in r.message for r in caplog.records)


@patch("lib.error_tracking.sentry_sdk.capture_exception")
def test_capture_exception_only_reports_when_enabled(mock_capture_exception, monkeypatch):
err = RuntimeError("boom")
Expand Down
Loading