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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion microbootstrap/instruments/sentry_instrument.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations
import contextlib
import functools
import typing

import orjson
Expand All @@ -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"})
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 53 additions & 3 deletions tests/instruments/test_sentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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"]
Loading