diff --git a/README.md b/README.md index bf5bb6a..b931df6 100644 --- a/README.md +++ b/README.md @@ -288,12 +288,27 @@ class YourSettings(BaseServiceSettings): sentry_integrations: list[Integration] = [] sentry_additional_params: dict[str, typing.Any] = {} sentry_tags: dict[str, str] | None = None + sentry_opentelemetry_trace_url_template: str | None = None ... # Other settings here ``` These settings are subsequently passed to the [sentry-sdk](https://pypi.org/project/sentry-sdk/) package, finalizing your Sentry integration. +Parameter descriptions: + +- `service_environment` - The environment name for Sentry events. +- `sentry_dsn` - The Data Source Name for your Sentry project. +- `sentry_traces_sample_rate` - The rate at which traces are sampled (via Sentry Tracing, not OpenTelemetry). +- `sentry_sample_rate` - The rate at which transactions are sampled. +- `sentry_max_breadcrumbs` - The maximum number of breadcrumbs to keep. +- `sentry_max_value_length` - The maximum length of values in Sentry events. +- `sentry_attach_stacktrace` - Whether to attach stacktraces to messages. +- `sentry_integrations` - A list of Sentry integrations to enable. +- `sentry_additional_params` - Additional parameters to pass to Sentry SDK. +- `sentry_tags` - Tags to apply to all Sentry events. +- `sentry_opentelemetry_trace_url_template` - Template for OpenTelemetry trace URLs to add to Sentry events (example: `"https://example.com/traces/{trace_id}"`). + ### [Prometheus](https://prometheus.io/) Prometheus integration presents a challenge because the underlying libraries for `FastAPI`, `Litestar` and `FastStream` differ significantly, making it impossible to unify them under a single interface. As a result, the Prometheus settings for `FastAPI`, `Litestar` and `FastStream` must be configured separately. diff --git a/microbootstrap/instruments/sentry_instrument.py b/microbootstrap/instruments/sentry_instrument.py index cddfcb7..49dd532 100644 --- a/microbootstrap/instruments/sentry_instrument.py +++ b/microbootstrap/instruments/sentry_instrument.py @@ -1,5 +1,6 @@ from __future__ import annotations import contextlib +import functools import typing import orjson @@ -24,6 +25,7 @@ class SentryConfig(BaseInstrumentConfig): sentry_additional_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) sentry_tags: dict[str, str] | None = None sentry_before_send: typing.Callable[[typing.Any, typing.Any], typing.Any | None] | None = None + sentry_opentelemetry_trace_url_template: str | None = None IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset({"event", "level", "logger", "tracing", "timestamp"}) @@ -58,6 +60,18 @@ def enrich_sentry_event_from_structlog_log(event: sentry_types.Event, _hint: sen return event +SENTRY_EXTRA_OTEL_TRACE_ID_KEY: typing.Final = "otelTraceID" +SENTRY_EXTRA_OTEL_TRACE_URL_KEY: typing.Final = "otelTraceURL" + + +def add_trace_url_to_event( + trace_link_template: str, event: sentry_types.Event, _hint: sentry_types.Hint +) -> sentry_types.Event: + if trace_link_template and (trace_id := event.get("extra", {}).get(SENTRY_EXTRA_OTEL_TRACE_ID_KEY)): + event["extra"][SENTRY_EXTRA_OTEL_TRACE_URL_KEY] = trace_link_template.replace("{trace_id}", str(trace_id)) + return event + + def wrap_before_send_callbacks(*callbacks: sentry_types.EventProcessor | None) -> sentry_types.EventProcessor: def run_before_send(event: sentry_types.Event, hint: sentry_types.Hint) -> sentry_types.Event | None: for callback in callbacks: @@ -89,7 +103,13 @@ def bootstrap(self) -> None: max_value_length=self.instrument_config.sentry_max_value_length, attach_stacktrace=self.instrument_config.sentry_attach_stacktrace, before_send=wrap_before_send_callbacks( - enrich_sentry_event_from_structlog_log, self.instrument_config.sentry_before_send + enrich_sentry_event_from_structlog_log, + functools.partial( + add_trace_url_to_event, self.instrument_config.sentry_opentelemetry_trace_url_template + ) + if self.instrument_config.sentry_opentelemetry_trace_url_template + else None, + self.instrument_config.sentry_before_send, ), integrations=self.instrument_config.sentry_integrations, **self.instrument_config.sentry_additional_params, diff --git a/tests/instruments/test_sentry.py b/tests/instruments/test_sentry.py index 76d7f12..e0e4818 100644 --- a/tests/instruments/test_sentry.py +++ b/tests/instruments/test_sentry.py @@ -2,17 +2,23 @@ import copy import typing from unittest import mock -from unittest.mock import patch import litestar import pytest from litestar.testing import TestClient as LitestarTestClient from microbootstrap.bootstrappers.litestar import LitestarSentryInstrument -from microbootstrap.instruments.sentry_instrument import SentryInstrument, enrich_sentry_event_from_structlog_log +from microbootstrap.instruments.sentry_instrument import ( + SENTRY_EXTRA_OTEL_TRACE_ID_KEY, + SENTRY_EXTRA_OTEL_TRACE_URL_KEY, + SentryInstrument, + add_trace_url_to_event, + enrich_sentry_event_from_structlog_log, +) if typing.TYPE_CHECKING: + import faker from sentry_sdk import _types as sentry_types from microbootstrap import SentryConfig @@ -61,7 +67,7 @@ async def error_handler() -> None: sentry_instrument.bootstrap() litestar_application: typing.Final = litestar.Litestar(route_handlers=[error_handler]) - with patch("sentry_sdk.Scope.capture_event") as mock_capture_event: + with mock.patch("sentry_sdk.Scope.capture_event") as mock_capture_event: with LitestarTestClient(app=litestar_application) as test_client: test_client.get("/test-error-handler") @@ -110,3 +116,47 @@ def test_skip(self, event: sentry_types.Event) -> None: ) def test_modify(self, event_before: sentry_types.Event, event_after: sentry_types.Event) -> None: assert enrich_sentry_event_from_structlog_log(event_before, mock.Mock()) == event_after + + +TRACE_URL_TEMPLATE = "https://example.com/traces/{trace_id}" + + +class TestSentryAddTraceUrlToEvent: + def test_add_trace_url_with_trace_id(self, faker: faker.Faker) -> None: + trace_id = faker.pystr() + event: sentry_types.Event = {"extra": {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: trace_id}} + + result = add_trace_url_to_event(TRACE_URL_TEMPLATE, event, mock.Mock()) + + assert result["extra"][SENTRY_EXTRA_OTEL_TRACE_URL_KEY] == f"https://example.com/traces/{trace_id}" + + @pytest.mark.parametrize( + "event", + [ + {}, + {"extra": {}}, + {"extra": {"other_field": "value"}}, + {"extra": {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: None}}, + {"extra": {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: ""}}, + ], + ) + def test_add_trace_url_without_trace_id(self, event: sentry_types.Event) -> None: + result = add_trace_url_to_event(TRACE_URL_TEMPLATE, event, mock.Mock()) + + assert SENTRY_EXTRA_OTEL_TRACE_URL_KEY not in result.get("extra", {}) + + def test_add_trace_url_empty_template(self, faker: faker.Faker) -> None: + event: sentry_types.Event = {"extra": {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: faker.pystr()}} + + result = add_trace_url_to_event("", event, mock.Mock()) + + assert SENTRY_EXTRA_OTEL_TRACE_URL_KEY not in result["extra"] + + @pytest.mark.parametrize("event", [{}, {"contexts": {}}]) + def test_add_trace_url_creates_contexts(self, faker: faker.Faker, event: sentry_types.Event) -> None: + event["extra"] = {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: faker.pystr()} + + result = add_trace_url_to_event(TRACE_URL_TEMPLATE, event, mock.Mock()) + + assert SENTRY_EXTRA_OTEL_TRACE_URL_KEY in result["extra"] + assert SENTRY_EXTRA_OTEL_TRACE_ID_KEY in result["extra"]