From eef7e5e701e2e55150251f7742d297d763f3ff26 Mon Sep 17 00:00:00 2001 From: Dan Fuchs Date: Mon, 13 Jan 2025 13:56:11 -0600 Subject: [PATCH] DM-48101: Sentry helpers fix docs fix linkcheck reorganize docs --- ...20250121_164728_danfuchs_sentry_helpers.md | 3 + docs/_rst_epilog.rst | 1 + docs/api.rst | 6 + docs/documenteer.toml | 5 + docs/user-guide/index.rst | 1 + docs/user-guide/sentry.rst | 237 ++++++++++++++++++ docs/user-guide/slack-webhook.rst | 4 + safir/pyproject.toml | 1 + safir/src/safir/sentry/__init__.py | 18 ++ safir/src/safir/sentry/_exceptions.py | 142 +++++++++++ safir/src/safir/sentry/_helpers.py | 64 +++++ safir/src/safir/sentry/py.typed | 0 safir/src/safir/testing/sentry.py | 119 +++++++++ safir/tests/conftest.py | 57 ++++- safir/tests/sentry_test.py | 94 +++++++ 15 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20250121_164728_danfuchs_sentry_helpers.md create mode 100644 docs/user-guide/sentry.rst create mode 100644 safir/src/safir/sentry/__init__.py create mode 100644 safir/src/safir/sentry/_exceptions.py create mode 100644 safir/src/safir/sentry/_helpers.py create mode 100644 safir/src/safir/sentry/py.typed create mode 100644 safir/src/safir/testing/sentry.py create mode 100644 safir/tests/sentry_test.py diff --git a/changelog.d/20250121_164728_danfuchs_sentry_helpers.md b/changelog.d/20250121_164728_danfuchs_sentry_helpers.md new file mode 100644 index 00000000..5211efea --- /dev/null +++ b/changelog.d/20250121_164728_danfuchs_sentry_helpers.md @@ -0,0 +1,3 @@ +### New features + +- Sentry instrumentation helpers diff --git a/docs/_rst_epilog.rst b/docs/_rst_epilog.rst index c7980f53..e4c084ee 100644 --- a/docs/_rst_epilog.rst +++ b/docs/_rst_epilog.rst @@ -25,6 +25,7 @@ .. _Sasquatch: https://sasquatch.lsst.io .. _schema registry: https://docs.confluent.io/platform/current/schema-registry/index.html .. _scriv: https://scriv.readthedocs.io/en/stable/ +.. _Sentry: https://sentry.io .. _semver: https://semver.org/ .. _SQLAlchemy: https://www.sqlalchemy.org/ .. _structlog: https://www.structlog.org/en/stable/ diff --git a/docs/api.rst b/docs/api.rst index b8cdf3bc..0e6a79e2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -94,6 +94,9 @@ API reference :include-all-objects: :inherited-members: +.. automodapi:: safir.sentry + :include-all-objects: + .. automodapi:: safir.slack.blockkit :include-all-objects: @@ -106,6 +109,9 @@ API reference .. automodapi:: safir.testing.kubernetes :include-all-objects: +.. automodapi:: safir.testing.sentry + :include-all-objects: + .. automodapi:: safir.testing.slack :include-all-objects: diff --git a/docs/documenteer.toml b/docs/documenteer.toml index 88809cac..c2a7f5db 100644 --- a/docs/documenteer.toml +++ b/docs/documenteer.toml @@ -77,9 +77,11 @@ arq = "https://arq-docs.helpmanual.io" click = "https://click.palletsprojects.com/en/stable" cryptography = "https://cryptography.io/en/latest" gidgethub = "https://gidgethub.readthedocs.io/en/latest" +pytest = "https://docs.pytest.org/en/stable" python = "https://docs.python.org/3" redis = "https://redis-py.readthedocs.io/en/stable" schema_registry = "https://marcosschroh.github.io/python-schema-registry-client" +sentry_sdk = "https://getsentry.github.io/sentry-python/" structlog = "https://www.structlog.org/en/stable" sqlalchemy = "https://docs.sqlalchemy.org/en/latest" vomodels = "https://vo-models.readthedocs.io/latest" @@ -89,4 +91,7 @@ ignore = [ # StackOverflow sometimes rejects all link checks from GitHub Actions. '^https://stackoverflow.com/questions/', '^https://github\.com/lsst-sqre/safir/issues/new', + '^https://github\.com/lsst-sqre/safir/issues/new', + # This anchor seems dynamically generated + '^https://github.com/getsentry/sentry/issues/64354#issuecomment-1927839632', ] diff --git a/docs/user-guide/index.rst b/docs/user-guide/index.rst index 9fe35758..3e7735b7 100644 --- a/docs/user-guide/index.rst +++ b/docs/user-guide/index.rst @@ -35,3 +35,4 @@ User guide click asyncio-queue uws/index + sentry diff --git a/docs/user-guide/sentry.rst b/docs/user-guide/sentry.rst new file mode 100644 index 00000000..ccc02e8e --- /dev/null +++ b/docs/user-guide/sentry.rst @@ -0,0 +1,237 @@ +################## +Integrating Sentry +################## + +`Sentry`_ is an exception reporting and tracing observability service. +It has great out-of-the-box integrations with many of the Python libaries that we use, including `FastAPI`_, `SQLAlchemy`_, and `arq`_. +Most apps can get a lot of value out of Sentry by doing nothing other than calling the `init function `_ early in their app and using some of the helpers described here. + +Instrumenting your application +============================== + +The simplest instrumentation involves calling ``sentry_sdk.init`` as early as possible in your app's ``main.py`` file. +You will need to provide at least: + +* A Sentry DSN associated with your app's Sentry project +* An environment name with which to tag Sentry events + +You can optionally provide: + +* The `~safir.sentry.before_send_handler` `before_send`_ handler, which adds the environment to the Sentry fingerprint, and handles :ref:`sentry-exception-types` appropriately. +* A value to configure the `traces_sample_rate`_ so you can easily enable or disable tracing from Phalanx without changing your app's code +* Other `configuration options`_. + +The ``sentry_sdk`` will automatically get the DSN and environment from the ``SENTRY_DSN`` and ``SENTRY_ENVIRONMENT`` environment vars, but you can also provide them explicitly via your app's config. +Unless you want to explicitly instrument app config initialization, you should probably provide these values with the rest of your app's config to keep all config in the same place. + +Your config file may look something like this: + +.. code-block:: python + :caption: src/myapp/config.py + + class Configuration(BaseSettings): + environment_name: Annotated[ + str, + Field( + alias="MYAPP_ENVIRONMENT_NAME", + description=( + "The Phalanx name of the Rubin Science Platform environment." + ), + ), + ] + + sentry_dsn: Annotated[ + str | None, + Field( + alias="MYAPP_SENTRY_DSN", + description="DSN for sending events to Sentry.", + ), + ] = None + + sentry_traces_sample_rate: Annotated[ + float, + Field( + alias="MYAPP_SENTRY_TRACES_SAMPLE_RATE", + description=( + "The percentage of transactions to send to Sentry, expressed " + "as a float between 0 and 1. 0 means send no traces, 1 means " + "send every trace." + ), + ge=0, + le=1, + ), + ] = 0 + + + config = Configuration() + +And your ``main.py`` might look like this: + +.. code-block:: python + :caption: src/myapp/main.py + + import sentry_sdk + + from safir.sentry import before_send_handler + from .config import config + + + sentry_sdk.init( + dsn=config.sentry_dsn, + environment=config.sentry_environment, + traces_sample_rate=config.sentry_traces_sample_rate, + before_send=before_send_handler, + ) + + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator: ... + + + app = FastAPI(title="My App", lifespan=lifespan, ...) + +.. _before_send: https://docs.sentry.io/platforms/python/configuration/options/#before-send +.. _traces_sample_rate: https://docs.sentry.io/platforms/python/configuration/options/#traces-sample-rate +.. _configuration options: https://docs.sentry.io/platforms/python/configuration/options/ + +.. _sentry-exception-types: + +Special Sentry exception types +============================== + +Similar to :ref:`slack-exceptions`, you can use `~safir.sentry.SentryException` to create custom exceptions that will send specific Sentry tags and contexts with any events that arise from them. +You need to use the `~safir.sentry.before_send_handler` handler for this to work. + +SentryException +--------------- + +You can define custom exceptions that inherit from `~safir.sentry.SentryException`. +These exceptions will have ``tags`` and ``contexts`` attributes. +If Sentry sends an event that arises from reporting one of these exceptions, the event will have those tags and contexts attached to it. + +.. note:: + + `Tags `_ are short key-value pairs that are indexed by Sentry. Use tags for small values that you would like to search by and aggregate over when analyzing multiple Sentry events in the Sentry UI. + `Contexts `_ are for more detailed information related to single events. You can not search by context values, but you can store more data in them. + You should use a tag for something like ``"query_type": "sync"`` and a context for something like ``"query_info": {"query_text": text}`` + +.. code-block:: python + + from safir.sentry import sentry_exception_handler, SentryException + + + sentry_sdk.init(before_send=sentry_exception_handler) + + + class SomeError(SentryException): + def __init__( + self, message: str, some_tag: str, some_context: dict[str, Any] + ) -> None: + super.__init__(message) + self.tags["some_tag"] = some_tag + self.contexts["some_context"] = some_context + + + raise SomeError( + "Some error!", some_tag="some_value", some_context={"foo": "bar"} + ) + +SentryWebException +------------------ + +Similar to :ref:`slack-web-exceptions`, you can use `~safir.sentry.SentryWebException` to report an `HTTPX`_ exception with helpful info in tags and contexts. + + +.. code-block:: python + + from httpx import AsyncClient, HTTPError + from safir.sentry import SentryWebException + + + class FooServiceError(SentryWebException): + """An error occurred sending a request to the foo service.""" + + + async def do_something(client: AsyncClient) -> None: + # ... set up some request to the foo service ... + try: + r = await client.get(url) + r.raise_for_status() + except HTTPError as e: + raise FooServiceError.from_exception(e) from e + +This will set an ``httpx_request_info`` context with the body, and these tags if the info is available: + +* ``httpx_request_method`` +* ``gafaelfaw_user`` +* ``httpx_request_url`` +* ``httpx_request_status`` + +Testing +======= + +Safir includes some functions to build `pytest`_ fixtures to assert you're sending accurate info with your Sentry events. + +* `~safir.testing.sentry.sentry_init_fixture` will yield a function that can be used to initialize Sentry such that it won't actually try to send any events. + It takes the same arguments as the `normal sentry init function `_. +* `~safir.testing.sentry.capture_events_fixture` will return a function that will patch the sentry client to collect events into a container instead of sending them over the wire, and return the container. + +These can be combined to create a pytest fixture that initializes Sentry in a way specific to your app, and passes the event container to your test function, where you can make assertions against the captured events. + +.. code-block:: python + :caption: conftest.py + + @pytest.fixture + def sentry_items( + monkeypatch: pytest.MonkeyPatch, + ) -> Generator[Captured]: + """Mock Sentry transport and yield a list that will contain all events.""" + with sentry_init_fixture() as init: + init( + traces_sample_rate=1.0, + before_send=before_send, + ) + events = capture_events_fixture(monkeypatch) + yield events() + +.. code-block:: python + :caption: my_test.py + + def test_spawn_timeout( + sentry_items: Captured, + ) -> None: + do_something_that_generates_an_error() + + # Check that an appropriate error was posted. + (error,) = sentry_items.errors + assert error["contexts"]["some_context"] == { + "foo": "bar", + "woo": "hoo", + } + assert error["exception"]["values"][0]["type"] == "SomeError" + assert error["exception"]["values"][0]["value"] == ( + "Something bad has happened, do something!!!!!" + ) + assert error["tags"] == { + "some_tag": "some_value", + "another_tag": "another_value", + } + assert error["user"] == {"username": "some_user"} + + # Check that an appropriate attachment was posted with the error. + (attachment,) = sentry_items.attachments + assert attachment.filename == "some_attachment.txt" + assert "blah" in attachment.bytes.decode() + + transaction = sentry_items.transactions[0] + assert transaction["spans"][0]["op"] == "some.operation" + +On a `~safir.testing.sentry.Captured` container, ``errors`` and ``transactions`` are dictionaries. +Their contents are described in the `Sentry docs `_. +You'll probably make most of your assertions against the keys: +* ``tags`` +* ``user`` +* ``contexts`` +* ``exception`` + +``attachments`` is a list of `~safir.testing.sentry.Attachment`. diff --git a/docs/user-guide/slack-webhook.rst b/docs/user-guide/slack-webhook.rst index 0c0c0162..7729b4e0 100644 --- a/docs/user-guide/slack-webhook.rst +++ b/docs/user-guide/slack-webhook.rst @@ -104,6 +104,8 @@ Finally, post the message to the Slack webhook: This method will never return an error. If posting the message to Slack fails, an exception will be logged using the logger provided when constructing the client, but the caller will not be notified. +.. _slack-exceptions: + Reporting an exception to a Slack webhook ========================================= @@ -160,6 +162,8 @@ For example: The full exception message (although not the traceback) is sent to Slack, so it should not contain any sensitive information, security keys, or similar data. +.. _slack-web-exceptions: + Reporting HTTPX exceptions -------------------------- diff --git a/safir/pyproject.toml b/safir/pyproject.toml index 7555caf6..a186b028 100644 --- a/safir/pyproject.toml +++ b/safir/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "pydantic-settings!=2.6.0,<3", "python-schema-registry-client>=2.6,<3", "safir-logging", + "sentry-sdk>=2,<3", "starlette<1", "structlog>=21.2.0", ] diff --git a/safir/src/safir/sentry/__init__.py b/safir/src/safir/sentry/__init__.py new file mode 100644 index 00000000..964c45bf --- /dev/null +++ b/safir/src/safir/sentry/__init__.py @@ -0,0 +1,18 @@ +"""Sentry helpers.""" + +from ._exceptions import SentryException, SentryWebException +from ._helpers import ( + before_send_handler, + duration, + fingerprint_env_handler, + sentry_exception_handler, +) + +__all__ = [ + "SentryException", + "SentryWebException", + "before_send_handler", + "duration", + "fingerprint_env_handler", + "sentry_exception_handler", +] diff --git a/safir/src/safir/sentry/_exceptions.py b/safir/src/safir/sentry/_exceptions.py new file mode 100644 index 00000000..d136e760 --- /dev/null +++ b/safir/src/safir/sentry/_exceptions.py @@ -0,0 +1,142 @@ +"""Exception helpers for Sentry instrumentation.""" + +from typing import Any, Self + +from httpx import HTTPError, HTTPStatusError +from sentry_sdk.types import Event + + +class SentryException(Exception): + """Enriches the Sentry context when paired with the ``enrich`` handler.""" + + def __init__( + self, + message: str, + ) -> None: + # Do not call the parent Exception constructor here, because calling + # it with a different number of arguments than the constructor + # argument of derived exceptions breaks pickling. See the end of + # https://github.com/python/cpython/issues/44791. This requires + # implementing __str__ rather than relying on the default behavior. + # + # Arguably, this is a bug in the __reduce__ method of BaseException + # and its interaction with constructors, but it appears to be hard to + # fix. See https://github.com/python/cpython/issues/76877. + self.message = message + self.tags: dict[str, str] = {} + self.contexts: dict[str, dict[str, Any]] = {} + + def __str__(self) -> str: + return self.message + + def enrich(self, event: Event) -> Event: + """Merge our tags and contexts into the event's.""" + event["tags"] = event.setdefault("tags", {}) + event["tags"].update(self.tags) + event["contexts"] = event.get("contexts", {}) | self.contexts + return event + + +class SentryWebException(SentryException): + """Parent class of exceptions arising from HTTPX_ failures. + + Captures additional information from any HTTPX_ exception. Intended to be + subclassed. + + Parameters + ---------- + message + Exception string value, which is the default Slack message. + method + Method of request. + url + URL of the request. + user + Username on whose behalf the request is being made. + status + Status code of failure, if any. + body + Body of failure message, if any. + """ + + @classmethod + def from_exception(cls, exc: HTTPError, user: str | None = None) -> Self: + """Create an exception from an HTTPX_ exception. + + Parameters + ---------- + exc + Exception from HTTPX. + user + User on whose behalf the request is being made, if known. + + Returns + ------- + SlackWebException + Newly-constructed exception. + """ + if isinstance(exc, HTTPStatusError): + status = exc.response.status_code + method = exc.request.method + message = f"Status {status} from {method} {exc.request.url}" + return cls( + message, + method=exc.request.method, + url=str(exc.request.url), + user=user, + status=status, + body=exc.response.text, + ) + else: + message = f"{type(exc).__name__}: {exc!s}" + + # All httpx.HTTPError exceptions have a slot for the request, + # initialized to None and then sometimes added by child + # constructors or during exception processing. The request + # property is a property method that raises RuntimeError if + # request has not been set, so we can't just check for None. + # Hence this approach of attempting to use the request and falling + # back on reporting less data if that raised any exception. + try: + return cls( + message, + method=exc.request.method, + url=str(exc.request.url), + user=user, + ) + except Exception: + return cls(message, user=user) + + def __init__( + self, + message: str, + *, + method: str | None = None, + url: str | None = None, + user: str | None = None, + status: int | None = None, + body: str | None = None, + ) -> None: + super().__init__(message) + self.method = method + self.url = url + self.status = status + self.body = body + self.user = user + + if self.method: + self.tags["httpx_request_method"] = self.method + if self.user: + self.tags["gafaelfaw_user"] = self.user + if self.url: + self.tags["httpx_request_url"] = self.url + if self.status: + self.tags["httpx_request_status"] = str(self.status) + if self.body: + self.contexts["httpx_request_info"] = {"body": self.body} + + def __str__(self) -> str: + result = self.message + if self.body: + result += f"\nBody:\n{self.body}\n" + return result diff --git a/safir/src/safir/sentry/_helpers.py b/safir/src/safir/sentry/_helpers.py new file mode 100644 index 00000000..e46445fd --- /dev/null +++ b/safir/src/safir/sentry/_helpers.py @@ -0,0 +1,64 @@ +"""Sentry helpers.""" + +from datetime import timedelta + +from sentry_sdk.tracing import Span +from sentry_sdk.types import Event, Hint + +from safir.datetime import current_datetime + +from ._exceptions import SentryException + +__all__ = [ + "before_send_handler", + "duration", + "fingerprint_env_handler", + "sentry_exception_handler", +] + + +def duration(span: Span) -> timedelta: + """Return the time spent in a span (to the present if not finished).""" + if span.timestamp is None: + timestamp = current_datetime(microseconds=True) + else: + timestamp = span.timestamp + + return timestamp - span.start_timestamp + + +def fingerprint_env_handler(event: Event, _: Hint) -> Event: + """Add the environment to the event fingerprint. + + Without doing this, Sentry groups events from all environments into the + same issue, and alerts that notify on new issues won't notify on a prod + event if an issue has already been created from an event from another env + :( + + https://github.com/getsentry/sentry/issues/64354 + """ + env = event.get("environment", "no environment") + fingerprint = event.get("fingerprint", []) + event["fingerprint"] = [ + "{{ default }}", + *fingerprint, + env, + ] + return event + + +def sentry_exception_handler(event: Event, hint: Hint) -> Event: + """Add tags and context from `~safir.sentry.SentryException`.""" + if exc_info := hint.get("exc_info"): + exc = exc_info[1] + if isinstance(exc, SentryException): + exc.enrich(event) + return event + + +def before_send_handler(event: Event, hint: Hint) -> Event: + """Add the env to the fingerprint, and enrich from + `~safir.sentry.SentryException`. + """ + event = fingerprint_env_handler(event, hint) + return sentry_exception_handler(event, hint) diff --git a/safir/src/safir/sentry/py.typed b/safir/src/safir/sentry/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/safir/src/safir/testing/sentry.py b/safir/src/safir/testing/sentry.py new file mode 100644 index 00000000..5adee146 --- /dev/null +++ b/safir/src/safir/testing/sentry.py @@ -0,0 +1,119 @@ +"""Unit test helpers for Sentry instrumentation. + +From the Sentry Python SDK's own unit tests: +https://github.com/getsentry/sentry-python/blob/c6a89d64db965fe0ece6de10df38ab936af8f5e4/tests/conftest.py +""" + +import contextlib +from collections.abc import Callable, Generator +from dataclasses import dataclass +from typing import Any + +import pytest +import sentry_sdk +from sentry_sdk.envelope import Envelope +from sentry_sdk.transport import Transport + +__all__ = [ + "Attachment", + "Captured", + "TestTransport", + "capture_events_fixture", + "sentry_init_fixture", +] + + +@dataclass +class Attachment: + """Contents and metadata of a Sentry attachment.""" + + filename: str + bytes: bytes + content_type: str + + +@dataclass +class Captured: + """A container for interesting items sent to Sentry.""" + + errors: list[dict[str, Any]] + transactions: list[dict[str, Any]] + attachments: list[Attachment] + + +class TestTransport(Transport): + """A transport that doesn't actually transport anything.""" + + def __init__(self) -> None: + Transport.__init__(self) + + def capture_envelope(self, envelope: Envelope) -> None: + """No-op capture_envelope for tests.""" + + +@contextlib.contextmanager +def sentry_init_fixture() -> Generator[Callable[..., None]]: + """Return an init function that injects a no-op transport.""" + + def inner(*a: Any, **kw: Any) -> None: + kw.setdefault("transport", TestTransport()) + client = sentry_sdk.Client(*a, **kw) + sentry_sdk.get_global_scope().set_client(client) + + old_client = sentry_sdk.get_global_scope().client + try: + sentry_sdk.get_current_scope().set_client(None) + yield inner + finally: + sentry_sdk.get_global_scope().set_client(old_client) + + +def capture_events_fixture( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[], Captured]: + """Return a function that returns a container with items sent to Sentry.""" + + def inner() -> Captured: + test_client = sentry_sdk.get_client() + if test_client.transport is None: + raise RuntimeError( + "Error patching Sentry transport: client.transport is None" + ) + old_capture_envelope = test_client.transport.capture_envelope + + captured = Captured(errors=[], transactions=[], attachments=[]) + + def append(envelope: Envelope) -> None: + for item in envelope: + match item.headers.get("type"): + case "event": + if item.payload.json is None: + raise ValueError( + "Sentry event unexpectedly missing json" + " payload" + ) + captured.errors.append(item.payload.json) + case "transaction": + if item.payload.json is None: + raise ValueError( + "Sentry transaction unexpectedly missing json" + " payload" + ) + captured.transactions.append(item.payload.json) + case "attachment": + captured.attachments.append( + Attachment( + filename=item.headers["filename"], + bytes=item.payload.get_bytes(), + content_type=item.headers["content_type"], + ) + ) + case _: + pass + return old_capture_envelope(envelope) + + monkeypatch.setattr(test_client.transport, "capture_envelope", append) + + return captured + + return inner diff --git a/safir/tests/conftest.py b/safir/tests/conftest.py index 14004c6d..04ca155e 100644 --- a/safir/tests/conftest.py +++ b/safir/tests/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator, Generator, Iterator from datetime import timedelta from pathlib import Path @@ -29,8 +29,18 @@ EventsConfiguration, KafkaMetricsConfiguration, ) +from safir.sentry import ( + before_send_handler, + fingerprint_env_handler, + sentry_exception_handler, +) from safir.testing.gcs import MockStorageClient, patch_google_storage from safir.testing.kubernetes import MockKubernetesApi, patch_kubernetes +from safir.testing.sentry import ( + Captured, + capture_events_fixture, + sentry_init_fixture, +) from safir.testing.slack import MockSlackWebhook, mock_slack_webhook from .support.kafka.container import FullKafkaContainer @@ -287,3 +297,48 @@ async def redis_client(redis: RedisContainer) -> AsyncIterator[Redis]: client = Redis(host=host, port=port, db=0) yield client await client.aclose() + + +@pytest.fixture +def sentry_fingerprint_items( + monkeypatch: pytest.MonkeyPatch, +) -> Generator[Captured]: + """Mock sentry transport and add env to event fingerprints.""" + with sentry_init_fixture() as init: + init( + environment="some_env", + traces_sample_rate=1.0, + before_send=fingerprint_env_handler, + ) + events = capture_events_fixture(monkeypatch) + yield events() + + +@pytest.fixture +def sentry_exception_items( + monkeypatch: pytest.MonkeyPatch, +) -> Generator[Captured]: + """Mock sentry transport and add SentryException info.""" + with sentry_init_fixture() as init: + init( + environment="some_env", + traces_sample_rate=1.0, + before_send=sentry_exception_handler, + ) + events = capture_events_fixture(monkeypatch) + yield events() + + +@pytest.fixture +def sentry_combo_items( + monkeypatch: pytest.MonkeyPatch, +) -> Generator[Captured]: + """Mock sentry transport and add all recommended before_send processing.""" + with sentry_init_fixture() as init: + init( + environment="some_env", + traces_sample_rate=1.0, + before_send=before_send_handler, + ) + events = capture_events_fixture(monkeypatch) + yield events() diff --git a/safir/tests/sentry_test.py b/safir/tests/sentry_test.py new file mode 100644 index 00000000..18292291 --- /dev/null +++ b/safir/tests/sentry_test.py @@ -0,0 +1,94 @@ +"""Tests for Sentry helpers.""" + +import pytest +import respx +import sentry_sdk +from httpx import AsyncClient, HTTPError, Response + +from safir.sentry import SentryException, SentryWebException +from safir.testing.sentry import Captured + + +def test_env_fingerprint_before_send( + sentry_fingerprint_items: Captured, +) -> None: + sentry_sdk.capture_exception(Exception("some error")) + (error,) = sentry_fingerprint_items.errors + assert error["fingerprint"] == ["{{ default }}", "some_env"] + + +def test_sentry_exception_before_send( + sentry_exception_items: Captured, +) -> None: + class SomeError(SentryException): ... + + exc = SomeError("some error") + exc.tags["woo"] = "hoo" + exc.contexts["foo"] = {"bar": "baz"} + + sentry_sdk.capture_exception(exc) + + (error,) = sentry_exception_items.errors + assert error["contexts"]["foo"] == {"bar": "baz"} + assert error["tags"] == {"woo": "hoo"} + + +def test_combined_before_send(sentry_combo_items: Captured) -> None: + class SomeError(SentryException): ... + + exc = SomeError("some error") + exc.tags["woo"] = "hoo" + exc.contexts["foo"] = {"bar": "baz"} + + sentry_sdk.capture_exception(exc) + + (error,) = sentry_combo_items.errors + assert error["contexts"]["foo"] == {"bar": "baz"} + assert error["fingerprint"] == ["{{ default }}", "some_env"] + assert error["tags"] == {"woo": "hoo"} + + +def test_sentry_exception(sentry_exception_items: Captured) -> None: + class SomeError(SentryException): ... + + exc = SomeError("some error") + exc.tags["woo"] = "hoo" + exc.contexts["foo"] = {"bar": "baz"} + + sentry_sdk.capture_exception(exc) + + (error,) = sentry_exception_items.errors + assert error["contexts"]["foo"] == {"bar": "baz"} + assert error["tags"] == {"woo": "hoo"} + + +@pytest.mark.asyncio +async def test_sentry_web_exception( + respx_mock: respx.Router, sentry_exception_items: Captured +) -> None: + class SomeError(SentryWebException): + pass + + respx_mock.get("https://example.org/").mock(return_value=Response(404)) + exception = None + try: + async with AsyncClient() as client: + r = await client.get("https://example.org/") + r.raise_for_status() + except HTTPError as e: + exception = SomeError.from_exception(e) + assert str(exception) == "Status 404 from GET https://example.org/" + sentry_sdk.capture_exception(exception) + + (error,) = sentry_exception_items.errors + assert error["exception"]["values"][0]["type"] == ( + "test_sentry_web_exception..SomeError" + ) + assert error["exception"]["values"][0]["value"] == ( + "Status 404 from GET https://example.org/" + ) + assert error["tags"] == { + "httpx_request_method": "GET", + "httpx_request_status": "404", + "httpx_request_url": "https://example.org/", + }