From fa0e33ebe6a6b18a272289cb1711d1fb4d625370 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 09:33:33 +0200 Subject: [PATCH 01/12] refactor(sentry): capture real events in tests Co-Authored-By: Claude Opus 5.5 --- tests/aignostics_foundry_core/sentry_test.py | 183 +++++++++++++------ 1 file changed, 129 insertions(+), 54 deletions(-) diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 899c02b..6798dba 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -1,21 +1,115 @@ """Tests for aignostics_foundry_core.sentry.""" +from collections.abc import Generator +from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock, patch import pytest +import sentry_sdk from pydantic import ValidationError +from sentry_sdk.client import NonRecordingClient +from sentry_sdk.transport import Transport from aignostics_foundry_core.foundry import set_context from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user from tests.conftest import TEST_PROJECT_NAME, TEST_PROJECT_PREFIX, make_context +if TYPE_CHECKING: + from sentry_sdk.envelope import Envelope + from sentry_sdk.integrations import Integration + + from aignostics_foundry_core.foundry import FoundryContext + _VALID_DSN = "https://abc123def456@o99999.ingest.de.sentry.io/1234567" _SENTRY_SET_USER = "sentry_sdk.set_user" _AUTH0_USER = "auth0|x" _SENTRY_PREFIX = f"{TEST_PROJECT_PREFIX}SENTRY_" -_SENTRY_SDK_INIT = "sentry_sdk.init" -_SENTRY_SDK_SET_CONTEXT = "sentry_sdk.set_context" -_SENTRY_SDK_IGNORE_LOGGER = "sentry_sdk.integrations.logging.ignore_logger" +_PROBE_MESSAGE = "probe" +_EVENT_ITEM_TYPES = {"event", "transaction"} + + +class _CapturingTransport(Transport): + """Sentry transport that keeps every envelope in memory instead of sending it.""" + + def __init__(self, options: dict[str, Any] | None = None) -> None: + """Initialise the transport with an empty envelope list.""" + super().__init__(options) + self.envelopes: list[Envelope] = [] + + def capture_envelope(self, envelope: "Envelope") -> None: + """Store *envelope* for later inspection.""" + self.envelopes.append(envelope) + + +class SentryCapture: + """Handle returned by the ``sentry_capture`` fixture. + + Starts Sentry through :func:`sentry_initialize` and gives access to the envelope + items that the SDK would have sent to Sentry. + """ + + def __init__(self, transport: _CapturingTransport) -> None: + """Wrap *transport*, which receives every envelope of the started client.""" + self._transport = transport + + def start( + self, + integrations: "list[Integration] | None" = None, + *, + context: "FoundryContext | None" = None, + ) -> bool: + """Call :func:`sentry_initialize` with *integrations* (and optional *context*). + + Returns: + bool: The return value of :func:`sentry_initialize`. + """ + return sentry_initialize(integrations, context=context) + + def items(self, item_type: str) -> list[dict[str, Any]]: + """Return the JSON payloads of all captured envelope items of *item_type* (e.g. ``"log"``). + + Calls :func:`sentry_sdk.flush` first so that buffered items arrive. + """ + return self._payloads({item_type}) + + @property + def events(self) -> list[dict[str, Any]]: + """Payloads of all captured ``event`` and ``transaction`` items, read after :func:`sentry_sdk.flush`.""" + return self._payloads(_EVENT_ITEM_TYPES) + + def _payloads(self, item_types: set[str]) -> list[dict[str, Any]]: + sentry_sdk.flush() + return [ + item.payload.json + for envelope in self._transport.envelopes + for item in envelope.items + if item.type in item_types and item.payload.json is not None + ] + + +@pytest.fixture +def sentry_capture(monkeypatch: pytest.MonkeyPatch) -> Generator[SentryCapture, None, None]: + """Enable Sentry with a valid DSN and capture all envelopes in memory. + + Test-specific ``{PREFIX}SENTRY_*`` env vars must be set before ``start()`` because + :func:`sentry_initialize` reads the settings when it runs. + + On teardown the client is closed, the isolation and current scopes are cleared and the + global scope gets a :class:`~sentry_sdk.client.NonRecordingClient`, so the next test + starts without an active client or user. + + Yields: + SentryCapture: Handle to start Sentry and read the captured payloads. + """ + monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") + monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) + transport = _CapturingTransport() + with patch("sentry_sdk.client.make_transport", return_value=transport): + yield SentryCapture(transport) + sentry_sdk.get_client().close() + sentry_sdk.get_isolation_scope().clear() + sentry_sdk.get_current_scope().clear() + sentry_sdk.get_global_scope().set_client(NonRecordingClient()) @pytest.mark.integration @@ -36,19 +130,14 @@ def test_sentry_initialize_returns_false_when_sdk_absent(self, monkeypatch: pyte result = sentry_initialize(integrations=None) assert result is False - def test_sentry_initialize_returns_true_and_calls_init_when_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Returns True and calls sentry_sdk.init with correct release when enabled with valid DSN.""" - monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") - monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - with ( - patch(_SENTRY_SDK_INIT) as mock_init, - patch(_SENTRY_SDK_SET_CONTEXT), - patch(_SENTRY_SDK_IGNORE_LOGGER), - ): - result = sentry_initialize(integrations=None) + def test_sentry_initialize_returns_true_when_enabled(self, sentry_capture: SentryCapture) -> None: + """Returns True and events carry the release ``{name}@{version_full}`` when enabled with a valid DSN.""" + result = sentry_capture.start() + sentry_sdk.capture_message(_PROBE_MESSAGE) + assert result is True - mock_init.assert_called_once() - assert mock_init.call_args.kwargs["release"] == f"{TEST_PROJECT_NAME}@0.0.0" + (event,) = sentry_capture.events + assert event["release"] == f"{TEST_PROJECT_NAME}@0.0.0" def test_sentry_initialize_returns_false_when_enabled_but_dsn_none(self, monkeypatch: pytest.MonkeyPatch) -> None: """Returns False when enabled but no DSN is configured.""" @@ -57,46 +146,32 @@ def test_sentry_initialize_returns_false_when_enabled_but_dsn_none(self, monkeyp result = sentry_initialize(integrations=None) assert result is False - def test_sentry_initialize_uses_context_project_name(self, monkeypatch: pytest.MonkeyPatch) -> None: - """sentry_sdk.init release tag uses the context project name.""" - monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") - monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - ctx = make_context() - with ( - patch(_SENTRY_SDK_INIT) as mock_init, - patch(_SENTRY_SDK_SET_CONTEXT), - patch(_SENTRY_SDK_IGNORE_LOGGER), - ): - result = sentry_initialize(integrations=None, context=ctx) - assert result is True - assert mock_init.call_args.kwargs["release"].startswith(f"{TEST_PROJECT_NAME}@") + def test_sentry_initialize_uses_context_project_name(self, sentry_capture: SentryCapture) -> None: + """The event release uses the project name of the given context.""" + ctx = make_context(name="other_project", version="1.2.3") + sentry_capture.start(context=ctx) + sentry_sdk.capture_message(_PROBE_MESSAGE) - def test_sentry_initialize_uses_context_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: - """sentry_sdk.init environment arg matches context.environment.""" - monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") - monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - ctx = make_context(environment="staging") - with ( - patch(_SENTRY_SDK_INIT) as mock_init, - patch(_SENTRY_SDK_SET_CONTEXT), - patch(_SENTRY_SDK_IGNORE_LOGGER), - ): - sentry_initialize(integrations=None, context=ctx) - assert mock_init.call_args.kwargs["environment"] == "staging" - - def test_sentry_initialize_uses_sentry_context_flags(self, monkeypatch: pytest.MonkeyPatch) -> None: - """sentry_sdk.set_context receives runtime mode flags from the context.""" - monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") - monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - ctx = make_context(is_test=True) - with ( - patch(_SENTRY_SDK_INIT), - patch(_SENTRY_SDK_SET_CONTEXT) as mock_set_ctx, - patch(_SENTRY_SDK_IGNORE_LOGGER), - ): - sentry_initialize(integrations=None, context=ctx) - ctx_data: dict[str, object] = mock_set_ctx.call_args.args[1] # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] - assert ctx_data["test_mode"] is True + (event,) = sentry_capture.events + assert event["release"] == "other_project@1.2.3" + + def test_sentry_initialize_uses_context_environment(self, sentry_capture: SentryCapture) -> None: + """The event environment matches context.environment.""" + sentry_capture.start(context=make_context(environment="staging")) + sentry_sdk.capture_message(_PROBE_MESSAGE) + + (event,) = sentry_capture.events + assert event["environment"] == "staging" + + def test_sentry_initialize_uses_sentry_context_flags(self, sentry_capture: SentryCapture) -> None: + """The ``aignx/base`` event context carries the runtime mode flags of the context.""" + sentry_capture.start(context=make_context(is_test=True)) + sentry_sdk.capture_message(_PROBE_MESSAGE) + + (event,) = sentry_capture.events + base_context = event["contexts"]["aignx/base"] + assert base_context["project_name"] == TEST_PROJECT_NAME + assert base_context["test_mode"] is True @pytest.mark.integration From c74ed07b5a2ae6683960733d018c2261d7c6b7aa Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 09:36:22 +0200 Subject: [PATCH 02/12] fix(sentry): stop sending frame local variables BREAKING CHANGE: Sentry events no longer include frame local variables. Set {PREFIX}SENTRY_INCLUDE_LOCAL_VARIABLES=true to restore them. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + src/aignostics_foundry_core/AGENTS.md | 2 +- src/aignostics_foundry_core/sentry.py | 13 +++++ tests/aignostics_foundry_core/sentry_test.py | 53 ++++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fc75964..376a631 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ set. | `{PREFIX}SENTRY_DSN` | unset | Sentry DSN (must be an HTTPS URL with a valid `ingest.*.sentry.io` domain). | | `{PREFIX}SENTRY_DEBUG` | `false` | Enable Sentry SDK debug mode. | | `{PREFIX}SENTRY_SEND_DEFAULT_PII` | `false` | Include personally-identifiable information in events. | +| `{PREFIX}SENTRY_INCLUDE_LOCAL_VARIABLES` | `false` | Include the local variables of each stack frame in error events. Locals can contain credentials and personal data. | | `{PREFIX}SENTRY_MAX_BREADCRUMBS` | `50` | Maximum breadcrumbs stored per event. | | `{PREFIX}SENTRY_SAMPLE_RATE` | `1.0` | Error event sample rate (0.0–1.0). | | `{PREFIX}SENTRY_TRACES_SAMPLE_RATE` | `0.1` | Transaction/trace sample rate. | diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index bf44aec..1201074 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -180,7 +180,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants. - **Key Features**: - - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` + - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` - `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; suppresses noisy loggers; returns `True` on success, `False` otherwise - `set_sentry_user(user, role_claim)` — maps Auth0 user claims (`sub` → `id`, `email`, `name`, …) into Sentry scope; pass `None` to clear context; no-op when `sentry_sdk` is absent - **Location**: `aignostics_foundry_core/sentry.py` diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index a7cce24..1b9d651 100644 --- a/src/aignostics_foundry_core/sentry.py +++ b/src/aignostics_foundry_core/sentry.py @@ -161,6 +161,18 @@ def __init__(self, **kwargs: Any) -> None: # ruff: ignore[any-type] ), ] + include_local_variables: Annotated[ + bool, + Field( + description=( + "Include the local variables of each stack frame in error events. Off by default because " + "locals can hold credentials and personal data " + "(https://docs.sentry.io/platforms/python/configuration/options/#include-local-variables)" + ), + default=False, + ), + ] + max_breadcrumbs: Annotated[ int, Field( @@ -263,6 +275,7 @@ def sentry_initialize( max_breadcrumbs=settings.max_breadcrumbs, debug=settings.debug, send_default_pii=settings.send_default_pii, + include_local_variables=settings.include_local_variables, sample_rate=settings.sample_rate, traces_sample_rate=settings.traces_sample_rate, profiles_sample_rate=settings.profiles_sample_rate, diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 6798dba..f78d5cc 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -26,6 +26,8 @@ _SENTRY_PREFIX = f"{TEST_PROJECT_PREFIX}SENTRY_" _PROBE_MESSAGE = "probe" _EVENT_ITEM_TYPES = {"event", "transaction"} +_SECRET_LOCAL_NAME = "client_secret" # ruff: ignore[hardcoded-password-string] +_SECRET_LOCAL_VALUE = "s3cr3t" # ruff: ignore[hardcoded-password-string] class _CapturingTransport(Transport): @@ -87,6 +89,30 @@ def _payloads(self, item_types: set[str]) -> list[dict[str, Any]]: ] +def _fail_with_secret_local() -> None: + """Raise from a frame that holds a secret in a local variable. + + Raises: + ValueError: Always. + """ + client_secret = _SECRET_LOCAL_VALUE + msg = f"login failed for a secret of length {len(client_secret)}" + raise ValueError(msg) + + +def _capture_exception_with_secret_local() -> None: + """Call :func:`_fail_with_secret_local` and send the exception to Sentry.""" + try: + _fail_with_secret_local() + except ValueError: + sentry_sdk.capture_exception() + + +def _exception_frames(event: dict[str, Any]) -> list[dict[str, Any]]: + """Return all stack frames of all exceptions in *event*.""" + return [frame for value in event["exception"]["values"] for frame in value["stacktrace"]["frames"]] + + @pytest.fixture def sentry_capture(monkeypatch: pytest.MonkeyPatch) -> Generator[SentryCapture, None, None]: """Enable Sentry with a valid DSN and capture all envelopes in memory. @@ -174,6 +200,33 @@ def test_sentry_initialize_uses_sentry_context_flags(self, sentry_capture: Sentr assert base_context["test_mode"] is True +@pytest.mark.integration +class TestSentryDataCollection: + """Tests for the data that Sentry events carry with default and opt-in settings.""" + + def test_exception_event_has_no_frame_vars_by_default(self, sentry_capture: SentryCapture) -> None: + """No stack frame of an exception event carries local variables at default settings.""" + sentry_capture.start() + _capture_exception_with_secret_local() + + (event,) = sentry_capture.events + frames = _exception_frames(event) + assert frames + assert all("vars" not in frame for frame in frames) + + def test_exception_event_has_frame_vars_when_enabled( + self, sentry_capture: SentryCapture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The raising frame carries its local variables when INCLUDE_LOCAL_VARIABLES is true.""" + monkeypatch.setenv(f"{_SENTRY_PREFIX}INCLUDE_LOCAL_VARIABLES", "true") + sentry_capture.start() + _capture_exception_with_secret_local() + + (event,) = sentry_capture.events + (frame,) = [f for f in _exception_frames(event) if f["function"] == _fail_with_secret_local.__name__] + assert _SECRET_LOCAL_NAME in frame["vars"] + + @pytest.mark.integration class TestSentrySettingsDsnValidation: """Tests for SentrySettings DSN edge-case validation paths.""" From f4b4baa96d9b186d0a4286af1fd6c21d3a73cc67 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 09:39:19 +0200 Subject: [PATCH 03/12] fix(sentry): stop sending request bodies Add SentrySettings.max_request_body_size (default "never") and pass it to sentry_sdk.init. The FastAPI integration sends JSON request bodies even when send_default_pii is false. BREAKING CHANGE: Sentry events no longer include request bodies. Set {PREFIX}SENTRY_MAX_REQUEST_BODY_SIZE to small, medium or always to restore them. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + src/aignostics_foundry_core/AGENTS.md | 2 +- src/aignostics_foundry_core/sentry.py | 14 +++++++ tests/aignostics_foundry_core/sentry_test.py | 40 ++++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 376a631..ea2186c 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ set. | `{PREFIX}SENTRY_DEBUG` | `false` | Enable Sentry SDK debug mode. | | `{PREFIX}SENTRY_SEND_DEFAULT_PII` | `false` | Include personally-identifiable information in events. | | `{PREFIX}SENTRY_INCLUDE_LOCAL_VARIABLES` | `false` | Include the local variables of each stack frame in error events. Locals can contain credentials and personal data. | +| `{PREFIX}SENTRY_MAX_REQUEST_BODY_SIZE` | `"never"` | Maximum size of HTTP request bodies in error events: `"never"`, `"small"` (up to 1 KB), `"medium"` (up to 10 KB) or `"always"`. Request bodies can contain credentials and personal data. The FastAPI integration sends JSON bodies even when `SEND_DEFAULT_PII` is `false`. | | `{PREFIX}SENTRY_MAX_BREADCRUMBS` | `50` | Maximum breadcrumbs stored per event. | | `{PREFIX}SENTRY_SAMPLE_RATE` | `1.0` | Error event sample rate (0.0–1.0). | | `{PREFIX}SENTRY_TRACES_SAMPLE_RATE` | `0.1` | Transaction/trace sample rate. | diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 1201074..ffcda96 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -180,7 +180,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants. - **Key Features**: - - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` + - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` - `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; suppresses noisy loggers; returns `True` on success, `False` otherwise - `set_sentry_user(user, role_claim)` — maps Auth0 user claims (`sub` → `id`, `email`, `name`, …) into Sentry scope; pass `None` to clear context; no-op when `sentry_sdk` is absent - **Location**: `aignostics_foundry_core/sentry.py` diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index 1b9d651..22d4ff9 100644 --- a/src/aignostics_foundry_core/sentry.py +++ b/src/aignostics_foundry_core/sentry.py @@ -173,6 +173,19 @@ def __init__(self, **kwargs: Any) -> None: # ruff: ignore[any-type] ), ] + max_request_body_size: Annotated[ + Literal["never", "small", "medium", "always"], + Field( + description=( + "Maximum size of HTTP request bodies attached to events. Off (never) by default because " + "request bodies can hold credentials and personal data, and integrations send JSON bodies " + "even when send_default_pii is false " + "(https://docs.sentry.io/platforms/python/configuration/options/#max-request-body-size)" + ), + default="never", + ), + ] + max_breadcrumbs: Annotated[ int, Field( @@ -276,6 +289,7 @@ def sentry_initialize( debug=settings.debug, send_default_pii=settings.send_default_pii, include_local_variables=settings.include_local_variables, + max_request_body_size=settings.max_request_body_size, sample_rate=settings.sample_rate, traces_sample_rate=settings.traces_sample_rate, profiles_sample_rate=settings.profiles_sample_rate, diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index f78d5cc..51de64d 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -1,11 +1,14 @@ """Tests for aignostics_foundry_core.sentry.""" +import json from collections.abc import Generator from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock, patch import pytest import sentry_sdk +from fastapi import FastAPI +from fastapi.testclient import TestClient from pydantic import ValidationError from sentry_sdk.client import NonRecordingClient from sentry_sdk.transport import Transport @@ -28,6 +31,7 @@ _EVENT_ITEM_TYPES = {"event", "transaction"} _SECRET_LOCAL_NAME = "client_secret" # ruff: ignore[hardcoded-password-string] _SECRET_LOCAL_VALUE = "s3cr3t" # ruff: ignore[hardcoded-password-string] +_FAILING_ROUTE = "/fail" class _CapturingTransport(Transport): @@ -113,6 +117,18 @@ def _exception_frames(event: dict[str, Any]) -> list[dict[str, Any]]: return [frame for value in event["exception"]["values"] for frame in value["stacktrace"]["frames"]] +def _post_secret_to_failing_fastapi_route() -> None: + """Send a JSON body with a secret to a FastAPI route that raises :class:`RuntimeError`.""" + app = FastAPI() + + @app.post(_FAILING_ROUTE) + def fail() -> None: # pyright: ignore[reportUnusedFunction] + msg = "request failed" + raise RuntimeError(msg) + + TestClient(app, raise_server_exceptions=False).post(_FAILING_ROUTE, json={_SECRET_LOCAL_NAME: _SECRET_LOCAL_VALUE}) + + @pytest.fixture def sentry_capture(monkeypatch: pytest.MonkeyPatch) -> Generator[SentryCapture, None, None]: """Enable Sentry with a valid DSN and capture all envelopes in memory. @@ -226,6 +242,30 @@ def test_exception_event_has_frame_vars_when_enabled( (frame,) = [f for f in _exception_frames(event) if f["function"] == _fail_with_secret_local.__name__] assert _SECRET_LOCAL_NAME in frame["vars"] + def test_failed_fastapi_request_event_has_no_request_data_by_default(self, sentry_capture: SentryCapture) -> None: + """The event of a failed FastAPI request carries request info but no request body at default settings. + + The SDK keeps the ``data`` key with an empty value and marks it as removed in ``_meta``. + """ + sentry_capture.start() + _post_secret_to_failing_fastapi_route() + + (event,) = sentry_capture.events + assert "request" in event + assert not event["request"].get("data") + assert _SECRET_LOCAL_VALUE not in json.dumps(event) + + def test_failed_fastapi_request_event_has_request_data_when_always( + self, sentry_capture: SentryCapture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The event of a failed FastAPI request carries the JSON body when MAX_REQUEST_BODY_SIZE is always.""" + monkeypatch.setenv(f"{_SENTRY_PREFIX}MAX_REQUEST_BODY_SIZE", "always") + sentry_capture.start() + _post_secret_to_failing_fastapi_route() + + (event,) = sentry_capture.events + assert event["request"]["data"][_SECRET_LOCAL_NAME] == _SECRET_LOCAL_VALUE + @pytest.mark.integration class TestSentrySettingsDsnValidation: From 91d60b1d29df08887c2706d5952e3197b0b5ca4b Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 09:46:01 +0200 Subject: [PATCH 04/12] fix(sentry): send only user and org IDs BREAKING CHANGE: set_sentry_user no longer sends email, name, nickname, given name, family name, picture, org name or updated_at to Sentry. Co-Authored-By: Claude Opus 5.5 --- src/aignostics_foundry_core/AGENTS.md | 2 +- src/aignostics_foundry_core/sentry.py | 22 +++--- tests/aignostics_foundry_core/sentry_test.py | 83 ++++++++++---------- 3 files changed, 53 insertions(+), 54 deletions(-) diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index ffcda96..b401802 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -182,7 +182,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Key Features**: - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` - `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; suppresses noisy loggers; returns `True` on success, `False` otherwise - - `set_sentry_user(user, role_claim)` — maps Auth0 user claims (`sub` → `id`, `email`, `name`, …) into Sentry scope; pass `None` to clear context; no-op when `sentry_sdk` is absent + - `set_sentry_user(user, role_claim)` — maps `sub` → `id`, `org_id` and the optional role claim into Sentry scope; no other claims (no email, name or other personal data); pass `None` to clear context; no-op when `sentry_sdk` is absent - **Location**: `aignostics_foundry_core/sentry.py` - **Dependencies**: `sentry-sdk>=2,<3` (mandatory); `loguru>=0.7,<1` - **Import**: `from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user` diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index 22d4ff9..22385d4 100644 --- a/src/aignostics_foundry_core/sentry.py +++ b/src/aignostics_foundry_core/sentry.py @@ -331,15 +331,21 @@ def set_sentry_user(user: dict[str, Any] | None, role_claim: str | None = None) This function should be called after successful authentication to enrich error reports with user context. + Only IDs go to Sentry: ``sub`` becomes ``id`` and ``org_id`` stays ``org_id``. + When ``role_claim`` is set, that claim becomes ``role``. All other claims (for + example ``email``, ``name`` or ``picture``) are dropped, also when + ``send_default_pii`` is enabled. + Args: - user: User dict from Auth0 containing fields like 'sub' (user ID), - 'email', 'name', 'org_id', 'org_name', 'role', etc. + user: User dict from Auth0 containing claims like 'sub' (user ID) and + 'org_id' (organization ID). Other claims are ignored. Pass None to clear user context. role_claim: Optional custom claim name for the user's role. If not specified, the role field will not be extracted. Example: - >>> set_sentry_user({"sub": "auth0|123", "email": "user@example.com", "org_id": "org123"}) + >>> set_sentry_user({"sub": "auth0|123", "org_id": "org123"}) + >>> set_sentry_user({"sub": "auth0|123", "https://x/role": "admin"}, role_claim="https://x/role") >>> set_sentry_user(None) # Clear user context """ if not find_spec("sentry_sdk"): @@ -351,18 +357,10 @@ def set_sentry_user(user: dict[str, Any] | None, role_claim: str | None = None) sentry_sdk.set_user(None) return - # Direct mappings from Auth0 user claims to Sentry user context + # Only IDs go to Sentry: personal data such as email or name stays out of error reports field_mappings: list[tuple[str, str]] = [ ("sub", "id"), # Auth0 user ID (e.g., "auth0|abc123") - ("email", "email"), - ("name", "name"), ("org_id", "org_id"), - ("org_name", "org_name"), - ("nickname", "nickname"), - ("given_name", "given_name"), - ("family_name", "family_name"), - ("picture", "picture"), - ("updated_at", "updated_at"), ] if role_claim: diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 51de64d..a1202bf 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -3,7 +3,7 @@ import json from collections.abc import Generator from typing import TYPE_CHECKING, Any -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest import sentry_sdk @@ -24,8 +24,21 @@ from aignostics_foundry_core.foundry import FoundryContext _VALID_DSN = "https://abc123def456@o99999.ingest.de.sentry.io/1234567" -_SENTRY_SET_USER = "sentry_sdk.set_user" _AUTH0_USER = "auth0|x" +_AUTH0_ORG_ID = "org_123" +_ROLE_CLAIM = "https://example.com/role" +_FULL_AUTH0_CLAIMS: dict[str, Any] = { + "sub": _AUTH0_USER, + "email": "user@example.com", + "name": "Test User", + "nickname": "tester", + "given_name": "Test", + "family_name": "User", + "picture": "https://example.com/avatar.png", + "org_id": _AUTH0_ORG_ID, + "org_name": "Example Org", + "updated_at": "2026-01-01T00:00:00.000Z", +} _SENTRY_PREFIX = f"{TEST_PROJECT_PREFIX}SENTRY_" _PROBE_MESSAGE = "probe" _EVENT_ITEM_TYPES = {"event", "transaction"} @@ -327,52 +340,40 @@ def test_sentry_settings_uses_context_env_prefix(self, monkeypatch: pytest.Monke assert settings.enabled is True -@pytest.mark.unit +@pytest.mark.integration class TestSetSentryUser: """Behavioural tests for set_sentry_user().""" - def test_set_sentry_user_maps_sub_to_id(self) -> None: - """set_sentry_user maps 'sub' claim to 'id' in Sentry user context.""" - mock_set_user = MagicMock() - with patch(_SENTRY_SET_USER, mock_set_user): - set_sentry_user({"sub": _AUTH0_USER}) - mock_set_user.assert_called_once_with({"id": _AUTH0_USER}) + def test_set_sentry_user_sends_only_id_and_org_id(self, sentry_capture: SentryCapture) -> None: + """Of a full Auth0 claim set, only ``sub`` (as ``id``) and ``org_id`` get to the event user.""" + sentry_capture.start() + set_sentry_user(_FULL_AUTH0_CLAIMS) + sentry_sdk.capture_message(_PROBE_MESSAGE) + + (event,) = sentry_capture.events + assert event["user"] == {"id": _AUTH0_USER, "org_id": _AUTH0_ORG_ID} + + def test_set_sentry_user_includes_role_from_claim(self, sentry_capture: SentryCapture) -> None: + """The event user carries the value of the custom role claim when role_claim is provided.""" + sentry_capture.start() + set_sentry_user({**_FULL_AUTH0_CLAIMS, _ROLE_CLAIM: "admin"}, role_claim=_ROLE_CLAIM) + sentry_sdk.capture_message(_PROBE_MESSAGE) + + (event,) = sentry_capture.events + assert event["user"]["role"] == "admin" + + def test_set_sentry_user_none_clears_user(self, sentry_capture: SentryCapture) -> None: + """set_sentry_user(None) removes a previously set user from later events.""" + sentry_capture.start() + set_sentry_user(_FULL_AUTH0_CLAIMS) + set_sentry_user(None) + sentry_sdk.capture_message(_PROBE_MESSAGE) - def test_set_sentry_user_none_clears_context(self) -> None: - """set_sentry_user(None) calls sentry_sdk.set_user(None) to clear context.""" - mock_set_user = MagicMock() - with patch(_SENTRY_SET_USER, mock_set_user): - set_sentry_user(None) - mock_set_user.assert_called_once_with(None) + (event,) = sentry_capture.events + assert "user" not in event def test_set_sentry_user_does_nothing_when_sdk_absent(self) -> None: """set_sentry_user is a no-op when sentry_sdk is not importable.""" with patch("aignostics_foundry_core.sentry.find_spec", return_value=None): # Should not raise even though sentry_sdk is unavailable set_sentry_user({"sub": _AUTH0_USER}) - - def test_set_sentry_user_includes_role_from_claim(self) -> None: - """set_sentry_user includes role from a custom claim when role_claim is provided.""" - mock_set_user = MagicMock() - with patch(_SENTRY_SET_USER, mock_set_user): - set_sentry_user( - {"sub": _AUTH0_USER, "https://my/role": "admin"}, - role_claim="https://my/role", - ) - assert mock_set_user.call_args[0][0]["role"] == "admin" - - def test_set_sentry_user_maps_multiple_fields(self) -> None: - """set_sentry_user maps all standard Auth0 fields to Sentry user context.""" - mock_set_user = MagicMock() - with patch(_SENTRY_SET_USER, mock_set_user): - set_sentry_user({ - "sub": "auth0|abc", - "email": "user@example.com", - "name": "Test User", - "org_id": "org_123", - }) - sentry_user = mock_set_user.call_args[0][0] - assert sentry_user["id"] == "auth0|abc" - assert sentry_user["email"] == "user@example.com" - assert sentry_user["name"] == "Test User" - assert sentry_user["org_id"] == "org_123" From 5c6a51d98f9fafb6490ed6674489cc27278c8163 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 09:48:23 +0200 Subject: [PATCH 05/12] test(sentry): read FastAPI error events by type At traces_sample_rate=0.1, a FastAPI request can also record a sampled transaction. Then `(event,) = sentry_capture.events` got two payloads and the Step 3 tests failed about 10% of the time. Read the error event with items("event"), and look for the secret in every payload. Co-Authored-By: Claude Opus 5.5 --- tests/aignostics_foundry_core/sentry_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index a1202bf..f6dacfd 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -259,14 +259,16 @@ def test_failed_fastapi_request_event_has_no_request_data_by_default(self, sentr """The event of a failed FastAPI request carries request info but no request body at default settings. The SDK keeps the ``data`` key with an empty value and marks it as removed in ``_meta``. + The request can also produce a sampled transaction, so the error event is read by its item + type, and the secret is looked for in every captured payload. """ sentry_capture.start() _post_secret_to_failing_fastapi_route() - (event,) = sentry_capture.events + (event,) = sentry_capture.items("event") assert "request" in event assert not event["request"].get("data") - assert _SECRET_LOCAL_VALUE not in json.dumps(event) + assert _SECRET_LOCAL_VALUE not in json.dumps(sentry_capture.events) def test_failed_fastapi_request_event_has_request_data_when_always( self, sentry_capture: SentryCapture, monkeypatch: pytest.MonkeyPatch @@ -276,7 +278,7 @@ def test_failed_fastapi_request_event_has_request_data_when_always( sentry_capture.start() _post_secret_to_failing_fastapi_route() - (event,) = sentry_capture.events + (event,) = sentry_capture.items("event") assert event["request"]["data"][_SECRET_LOCAL_NAME] == _SECRET_LOCAL_VALUE From 8055f9c5b6ad8e0f40df6e25a98f7628448efb4a Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 09:58:36 +0200 Subject: [PATCH 06/12] fix(otel): apply log level and filter to OTLP sink BREAKING CHANGE: the OTLP log sink drops records below {PREFIX}LOG_LEVEL and records that the boot() log_filter rejects. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 + src/aignostics_foundry_core/AGENTS.md | 20 +++- src/aignostics_foundry_core/boot.py | 6 +- src/aignostics_foundry_core/otel.py | 34 ++++++- tests/aignostics_foundry_core/boot_test.py | 36 +++++++- tests/aignostics_foundry_core/conftest.py | 101 +++++++++++++++++++++ tests/aignostics_foundry_core/otel_test.py | 42 ++++++++- 7 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 tests/aignostics_foundry_core/conftest.py diff --git a/README.md b/README.md index ea2186c..4308ec2 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,10 @@ via OTLP/gRPC, e.g. to the internal OTel gateway backing the | `{PREFIX}OTEL_METRICS_ENABLED` | `true` | Export metrics (once `ENABLED`). | | `{PREFIX}OTEL_LOGS_ENABLED` | `false` | Bridge loguru records into OTLP log export (once `ENABLED`). | +The OTLP log sink uses the same `{PREFIX}LOG_LEVEL` as the stderr and file sinks. It also uses the +`log_filter` that the service passes to `boot()`. Records below the log level, and records that the +filter rejects, do not go to the OTLP endpoint. + Endpoint, service name, and all other exporter behaviour come from the **standard, unprefixed** [OpenTelemetry environment variables](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) the SDK reads itself — not project-prefixed settings: diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index b401802..4546c83 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -16,10 +16,11 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei | **api** | Consolidated API sub-package | Re-exports all public symbols from `api.exceptions`, `api.auth`, and `api.core`; import any API symbol directly from `aignostics_foundry_core.api` | | **log** | Configurable loguru logging initialisation | `logging_initialize(filter_func=None, *, context=None)`, `LogSettings` (env-prefix configurable), `InterceptHandler` for stdlib-to-loguru bridging | | **sentry** | Configurable Sentry integration | `sentry_initialize(integrations, *, context=None)`, `SentrySettings` (env-prefix configurable), `set_sentry_user(user, role_claim)` for Auth0 user context | +| **otel** | Configurable OpenTelemetry integration | `otel_initialize(*, context=None, instrumentors=None, log_filter=None)`, `OTelSettings` (env-prefix configurable), `default_otel_instrumentors()`, `instrument_fastapi(app)`; the OTLP log sink uses `{PREFIX}LOG_LEVEL` and `log_filter` | | **service** | FastAPI-injectable base service | `BaseService` ABC with `get_service()` (cached per-class FastAPI `Depends` factory), `key()`, and abstract `health()` / `info()` methods; concrete subclasses implement health checks and module info | | **database** | Async SQLAlchemy session management + DB settings | `DatabaseSettings` (`OpaqueSettings` subclass; env prefix defaults to `{ctx.env_prefix}DB_`; `url: SecretStr` (required; always access via `get_url()`, not directly); `get_url()` with optional database `name` substitution); `init_engine(db_url=None, pool_size=None, pool_max_overflow=None, pool_timeout=None)` — all params optional, fall back to active context when `None`; `dispose_engine()`, `get_db_session()` (FastAPI dependency), `execute_with_session(func, …)`, `cli_run_with_db(func, …, db_url=None)`, `cli_run_with_engine(func, …, db_url=None)`, `with_engine` dual-mode decorator (supports `@with_engine`, `@with_engine()`, `@with_engine(db_url=…)`); auto-resets engine after `fork()` | | **cli** | Typer CLI preparation utilities | `prepare_cli(cli, epilog, *, context=None)` — discovers and registers subcommands via `locate_implementations`, sets epilog recursively, installs `no_args_is_help` workaround; `no_args_is_help_workaround(ctx)` — raises `typer.Exit` when no subcommand is invoked | -| **boot** | Application / library boot sequence | `boot(context, sentry_integrations, log_filter, show_cmdline)` — runs once per process: parses `--env` CLI args, initialises logging and Sentry, amends the SSL trust chain via *truststore* and *certifi*, and logs boot/shutdown messages | +| **boot** | Application / library boot sequence | `boot(context, sentry_integrations, otel_instrumentors, log_filter, show_cmdline)` — runs once per process: parses `--env` CLI args, initialises logging, Sentry and OpenTelemetry (`log_filter` applies to the stderr, file and OTLP log sinks), amends the SSL trust chain via *truststore* and *certifi*, and logs boot/shutdown messages | | **user_agent** | Parameterised HTTP user-agent string builder | `user_agent(project_name, version, repository_url)` — builds `{project_name}-python-sdk/{version} (…)` string including platform info, current test, and GitHub Actions run URL | | **gui** | NiceGUI page helpers, auth decorators, and nav builder | `GUINamespace` (configurable page decorator namespace), `gui` (default singleton), `page_public/authenticated/admin/internal/internal_admin` decorators, `get_gui_user`, `require_gui_user`, `BaseNavBuilder`, `NavItem`, `NavGroup`, `gui_get_nav_groups(*, context=None)`, `BasePageBuilder`, `gui_register_pages(*, context=None)`, `gui_run(*, context=None, …)`; constants `WINDOW_SIZE`, `BROWSER_RECONNECT_TIMEOUT`, `RESPONSE_TIMEOUT` | | **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory | @@ -150,11 +151,12 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Provides a single, idempotent `boot()` entry-point that initialises the full observability and SSL stack in the correct order. All project-specific metadata is injected as parameters so the function is reusable across any project. - **Key Features**: - - `boot(context, sentry_integrations, log_filter, show_cmdline)` — runs once per process; subsequent calls are silent no-ops + - `boot(context, sentry_integrations, otel_instrumentors, log_filter, show_cmdline)` — runs once per process; subsequent calls are silent no-ops - Parses `--env`/`-e KEY=VALUE` CLI arguments: vars matching `{PROJECT_NAME_UPPER}_*` are injected into `os.environ` and removed from `sys.argv` - Calls `logging_initialize` with project metadata - Calls `_amend_ssl_trust_chain`: injects *truststore* into the SSL context (if available) and sets `SSL_CERT_FILE` to the *certifi* bundle path when no system CA bundle is detected - Calls `sentry_initialize`; reads deployment environment from `{PROJECT_NAME_UPPER}_ENVIRONMENT` env var (default: `"production"`) + - Calls `otel_initialize(context, instrumentors, log_filter)`; the same `log_filter` goes to `logging_initialize` and to the OTLP log sink - Logs a boot message (project, version, pid, parent process, optional cmdline) at DEBUG level - Registers an atexit handler that logs a shutdown trace (skipped inside pytest to avoid closed-stream errors) - **Location**: `aignostics_foundry_core/boot.py` @@ -187,6 +189,20 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Dependencies**: `sentry-sdk>=2,<3` (mandatory); `loguru>=0.7,<1` - **Import**: `from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user` +### otel + +**Configurable OpenTelemetry integration for traces, metrics and logs** + +- **Purpose**: Sets up the process-wide OTLP/gRPC `TracerProvider`, `MeterProvider` and `LoggerProvider`, each gated by its own setting, and bridges loguru records into OTLP logs. +- **Key Features**: + - `OTelSettings(OpaqueSettings)` — env prefix `{ctx.env_prefix}OTEL_`. Fields: `enabled` (default `False`), `traces_enabled` (default `True`), `metrics_enabled` (default `True`), `logs_enabled` (default `False`). Endpoint, service name and exporter options come from the standard unprefixed `OTEL_*` env vars + - `otel_initialize(*, context=None, instrumentors=None, log_filter=None)` — returns `False` unless enabled and `OTEL_EXPORTER_OTLP_ENDPOINT` is set; applies `instrumentors` (default `default_otel_instrumentors()`) when traces are on + - OTLP log sink: uses `LogSettings.level` (`{PREFIX}LOG_LEVEL`) and drops records that `log_filter` rejects, the same as the stderr and file sinks; also drops the self-diagnostics of `opentelemetry` and `grpc` + - `instrument_fastapi(app)` — request-level tracing for a FastAPI app; `init_api` calls it +- **Location**: `aignostics_foundry_core/otel.py` +- **Dependencies**: `opentelemetry-sdk`, `opentelemetry-exporter-otlp-proto-grpc`, OTel instrumentations for FastAPI, httpx and SQLAlchemy, `opentelemetry-resourcedetector-gcp` (all mandatory); `aignostics_foundry_core.log` +- **Import**: `from aignostics_foundry_core.otel import OTelSettings, otel_initialize, instrument_fastapi` + ### models **Shared output format enum for CLI and API responses** diff --git a/src/aignostics_foundry_core/boot.py b/src/aignostics_foundry_core/boot.py index e5c77da..dcda666 100644 --- a/src/aignostics_foundry_core/boot.py +++ b/src/aignostics_foundry_core/boot.py @@ -83,7 +83,9 @@ def boot( default) uses that function's own best-practice defaults (currently ``HTTPXClientInstrumentor``); pass ``[]`` to opt out. log_filter: Optional loguru filter callable forwarded to - :func:`~aignostics_foundry_core.log.logging_initialize`. + :func:`~aignostics_foundry_core.log.logging_initialize` (stderr and + file sinks) and to :func:`~aignostics_foundry_core.otel.otel_initialize` + (OTLP log sink). show_cmdline: Whether to include the process command line in the boot log message (default: ``True``). """ @@ -100,7 +102,7 @@ def boot( integrations=sentry_integrations, context=ctx, ) - otel_initialized = otel_initialize(context=ctx, instrumentors=otel_instrumentors) + otel_initialized = otel_initialize(context=ctx, instrumentors=otel_instrumentors, log_filter=log_filter) _log_boot_message( context=ctx, show_cmdline=show_cmdline, diff --git a/src/aignostics_foundry_core/otel.py b/src/aignostics_foundry_core/otel.py index 0fb1063..d2e2080 100644 --- a/src/aignostics_foundry_core/otel.py +++ b/src/aignostics_foundry_core/otel.py @@ -71,6 +71,7 @@ from pydantic_settings import SettingsConfigDict from aignostics_foundry_core.foundry import get_context +from aignostics_foundry_core.log import LogSettings from aignostics_foundry_core.settings import OpaqueSettings if TYPE_CHECKING: @@ -329,6 +330,7 @@ def otel_initialize( *, context: FoundryContext | None = None, instrumentors: list[BaseInstrumentor] | None = None, + log_filter: Callable[[Record], bool] | None = None, ) -> bool: """Initialize OpenTelemetry tracing, metrics, and/or logs. @@ -344,7 +346,10 @@ def otel_initialize( ``logging``) via a loguru sink that forwards each record into OTel's own ``LoggingHandler`` — loguru has no first-party OTel integration, so this is the simplest way to reuse the SDK's own record conversion and trace - correlation instead of duplicating it. + correlation instead of duplicating it. The sink drops records below + :attr:`~aignostics_foundry_core.log.LogSettings.level` (``{PREFIX}LOG_LEVEL``) + and records that *log_filter* rejects, the same as the stderr and file sinks + that :func:`~aignostics_foundry_core.log.logging_initialize` adds. Args: context: :class:`~aignostics_foundry_core.foundry.FoundryContext` providing @@ -356,6 +361,11 @@ def otel_initialize( ``instrument_fastapi``'s purpose). ``None`` (the default) uses :func:`default_otel_instrumentors`; pass ``[]`` to opt out entirely, or a longer list to opt into more than the default. + log_filter: Optional loguru filter callable for the OTLP log sink; receives + a ``Record`` and returns ``True`` to keep it, ``False`` to drop it. + :func:`~aignostics_foundry_core.boot.boot` passes the same filter that + it gives to :func:`~aignostics_foundry_core.log.logging_initialize`. + ``None`` (the default) keeps all records at or above the log level. Returns: bool: ``True`` if OpenTelemetry was initialised successfully, ``False`` otherwise. @@ -413,7 +423,8 @@ def otel_initialize( _otel_metrics_initialize(resource) if settings.logs_enabled: - _otel_logs_initialize(resource) + log_level = LogSettings(_env_file=ctx.env_file).level # pyright: ignore[reportCallIssue] + _otel_logs_initialize(resource, level=log_level, log_filter=log_filter) logger.trace("OpenTelemetry integration initialized, exporting to {}.", os.environ[_OTEL_EXPORTER_OTLP_ENDPOINT]) @@ -511,7 +522,12 @@ def _otel_metrics_initialize(resource: Resource) -> None: atexit.register(meter_provider.shutdown) -def _otel_logs_initialize(resource: Resource) -> None: +def _otel_logs_initialize( + resource: Resource, + *, + level: str, + log_filter: Callable[[Record], bool] | None, +) -> None: """Set up the OTLP ``LoggerProvider`` and bridge loguru records into it. Split out of :func:`otel_initialize` since logs export is opt-in via @@ -524,8 +540,13 @@ def _otel_logs_initialize(resource: Resource) -> None: often what explains *why* the process is exiting), so this flush matters most here. + The loguru sink uses *level* and drops a record if + :func:`_otel_log_sink_filter` or *log_filter* rejects it. + Args: resource: The shared OTel ``Resource`` used by the tracer/meter providers. + level: Minimum loguru level name for the OTLP log sink. + log_filter: Optional service filter; ``None`` keeps all records. """ # opentelemetry-python's logs API is still under a leading-underscore module path # even in stable releases (see open-telemetry/opentelemetry-python#3565) — noqa/ @@ -550,9 +571,14 @@ def _otel_logs_initialize(resource: Resource) -> None: logger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter())) logs.set_logger_provider(logger_provider) atexit.register(logger_provider.shutdown) + + def sink_filter(record: Record) -> bool: + return _otel_log_sink_filter(record) and (log_filter is None or log_filter(record)) + logger.add( _make_otel_log_sink(LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)), - filter=_otel_log_sink_filter, + level=level, + filter=sink_filter, ) diff --git a/tests/aignostics_foundry_core/boot_test.py b/tests/aignostics_foundry_core/boot_test.py index 0eb951a..f2931fb 100644 --- a/tests/aignostics_foundry_core/boot_test.py +++ b/tests/aignostics_foundry_core/boot_test.py @@ -2,18 +2,27 @@ from __future__ import annotations +import logging import os import ssl import sys import types -from unittest.mock import MagicMock +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch +import certifi import pytest import aignostics_foundry_core.boot as boot_mod from tests.conftest import TEST_PROJECT_NAME, TEST_PROJECT_PREFIX, make_context +if TYPE_CHECKING: + from tests.aignostics_foundry_core.conftest import OtlpLogCapture + _OTHER_PROJECT = "otherapp" +_REJECTED_LOGGER = "noisy.lib" +_REJECTED_MARKER = "boot_otlp_rejected_marker_4c7d" +_KEPT_MARKER = "boot_otlp_kept_marker_8f2b" @pytest.mark.unit @@ -134,3 +143,28 @@ def test_boot_forwards_otel_instrumentors_to_otel_initialize(monkeypatch: pytest ) assert mock_otel.call_args.kwargs["instrumentors"] is sentinel_instrumentors + + +@pytest.mark.integration +def test_boot_applies_log_filter_to_otlp_sink( + monkeypatch: pytest.MonkeyPatch, otlp_log_exporter: OtlpLogCapture +) -> None: + """boot() gives its log_filter to the OTLP log sink, which then drops the rejected records.""" + monkeypatch.setattr(boot_mod, "_boot_called", False) + monkeypatch.setattr(sys, "argv", ["boot_test"]) + # Keep boot() from changing the process-wide SSL setup and SSL_CERT_FILE. + monkeypatch.setenv("SSL_CERT_FILE", certifi.where()) + + with patch("truststore.inject_into_ssl"): + boot_mod.boot( + context=make_context(), + sentry_integrations=None, + log_filter=lambda record: record["name"] != _REJECTED_LOGGER, + ) + + logging.getLogger(_REJECTED_LOGGER).warning(_REJECTED_MARKER) + logging.getLogger(TEST_PROJECT_NAME).warning(_KEPT_MARKER) + + bodies = otlp_log_exporter.bodies + assert _KEPT_MARKER in bodies + assert _REJECTED_MARKER not in bodies diff --git a/tests/aignostics_foundry_core/conftest.py b/tests/aignostics_foundry_core/conftest.py new file mode 100644 index 0000000..a0998d2 --- /dev/null +++ b/tests/aignostics_foundry_core/conftest.py @@ -0,0 +1,101 @@ +"""Shared fixtures for the aignostics_foundry_core test modules.""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest +from loguru import logger +from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter + +from aignostics_foundry_core.log import InterceptHandler +from tests.conftest import TEST_PROJECT_PREFIX + +if TYPE_CHECKING: + from collections.abc import Generator + + from opentelemetry.sdk._logs import LoggerProvider # pyright: ignore[reportPrivateImportUsage] + +_OTEL_PREFIX = f"{TEST_PROJECT_PREFIX}OTEL_" +_OTLP_ENDPOINT = "https://otel-gateway.example.com:4317" + +# otel_initialize() writes these with os.environ.setdefault(), so monkeypatch cannot undo them. +_OTEL_SDK_ENV_DEFAULTS = ("OTEL_SERVICE_NAME", "OTEL_SEMCONV_STABILITY_OPT_IN", "OTEL_EXPORTER_OTLP_CERTIFICATE") + + +class OtlpLogCapture: + """Access to the log records that the OTLP log sink exports.""" + + def __init__(self, exporter: InMemoryLogRecordExporter, set_logger_provider: MagicMock) -> None: + """Keep the in-memory exporter and the patched ``set_logger_provider``. + + Args: + exporter: The exporter that the ``LoggerProvider`` sends batches to. + set_logger_provider: The patched ``opentelemetry._logs.set_logger_provider``. + """ + self._exporter = exporter + self._set_logger_provider = set_logger_provider + + @property + def provider(self) -> LoggerProvider | None: + """The ``LoggerProvider`` that ``otel_initialize`` built, or ``None`` if it built none.""" + call = self._set_logger_provider.call_args + return None if call is None else call.args[0] + + @property + def bodies(self) -> list[str]: + """Bodies of the exported log records, after a flush of the ``LoggerProvider``.""" + provider = self.provider + assert provider is not None, "otel_initialize() did not build a LoggerProvider" + provider.force_flush() + return [str(data.log_record.body) for data in self._exporter.get_finished_logs()] + + +@pytest.fixture +def otlp_log_exporter(monkeypatch: pytest.MonkeyPatch) -> Generator[OtlpLogCapture, None, None]: + """Enable only the OTel logs signal and export its records to memory. + + Traces and metrics are off, so ``otel_initialize`` applies no global + instrumentors and starts no span or metric export threads. The fixture + patches only OpenTelemetry symbols, so the global ``LoggerProvider`` stays + unset. + + Teardown shuts the ``LoggerProvider`` down, removes all loguru sinks, removes + the ``InterceptHandler`` that ``logging_initialize`` puts on the stdlib root + logger, and restores the root logger level and the ``OTEL_*`` defaults. + + Yields: + OtlpLogCapture: Access to the exported log records. + """ + monkeypatch.setenv(f"{_OTEL_PREFIX}ENABLED", "true") + monkeypatch.setenv(f"{_OTEL_PREFIX}LOGS_ENABLED", "true") + monkeypatch.setenv(f"{_OTEL_PREFIX}TRACES_ENABLED", "false") + monkeypatch.setenv(f"{_OTEL_PREFIX}METRICS_ENABLED", "false") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _OTLP_ENDPOINT) + saved_env = {name: os.environ.get(name) for name in _OTEL_SDK_ENV_DEFAULTS} + root_logger = logging.getLogger() + saved_root_level = root_logger.level + + exporter = InMemoryLogRecordExporter() + with ( + patch("opentelemetry.exporter.otlp.proto.grpc._log_exporter.OTLPLogExporter", return_value=exporter), + patch("opentelemetry._logs.set_logger_provider") as set_logger_provider, + ): + capture = OtlpLogCapture(exporter, set_logger_provider) + try: + yield capture + finally: + if capture.provider is not None: + capture.provider.shutdown() + logger.remove() + for handler in [h for h in root_logger.handlers if isinstance(h, InterceptHandler)]: + root_logger.removeHandler(handler) + root_logger.setLevel(saved_root_level) + for name, value in saved_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value diff --git a/tests/aignostics_foundry_core/otel_test.py b/tests/aignostics_foundry_core/otel_test.py index b6aafa8..5a29b06 100644 --- a/tests/aignostics_foundry_core/otel_test.py +++ b/tests/aignostics_foundry_core/otel_test.py @@ -9,6 +9,7 @@ from opentelemetry.sdk.resources import Resource from aignostics_foundry_core.foundry import set_context +from aignostics_foundry_core.log import logging_initialize from aignostics_foundry_core.otel import ( _OS_CA_BUNDLE_PATH, OTelSettings, @@ -22,6 +23,7 @@ instrument_fastapi, otel_initialize, ) +from tests.aignostics_foundry_core.conftest import OtlpLogCapture from tests.conftest import TEST_PROJECT_NAME, TEST_PROJECT_PREFIX, make_context _OTEL_PREFIX = f"{TEST_PROJECT_PREFIX}OTEL_" @@ -48,6 +50,12 @@ def _clean_otlp_certificate_env(monkeypatch: pytest.MonkeyPatch) -> None: _LOGURU_LOGGER_ADD = "aignostics_foundry_core.otel.logger.add" +_DEBUG_MARKER = "otlp_sink_debug_marker_5a1c" +_INFO_MARKER = "otlp_sink_info_marker_7d3e" +_REJECTED_LOGGER = "noisy.lib" +_KEPT_LOGGER = "app" +_REJECTED_MARKER = "otlp_sink_rejected_marker_2b8f" +_KEPT_MARKER = "otlp_sink_kept_marker_9e4a" @pytest.mark.integration @@ -396,7 +404,7 @@ def test_logs_initialize_noops_when_already_registered(self) -> None: patch(_LOGS_SET_LOGGER_PROVIDER) as mock_set, patch(_LOGURU_LOGGER_ADD) as mock_add, ): - _otel_logs_initialize(MagicMock()) + _otel_logs_initialize(MagicMock(), level="INFO", log_filter=None) mock_set.assert_not_called() mock_add.assert_not_called() @@ -613,3 +621,35 @@ def test_instrument_fastapi_noops_when_no_tracer_provider_registered(self) -> No result = instrument_fastapi(MagicMock()) assert result is False mock_instrument.assert_not_called() + + +@pytest.mark.integration +def test_otlp_log_sink_drops_records_below_log_level( + monkeypatch: pytest.MonkeyPatch, otlp_log_exporter: OtlpLogCapture +) -> None: + """The OTLP log sink exports only records at or above {PREFIX}LOG_LEVEL.""" + from loguru import logger + + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}LOG_LEVEL", "INFO") + assert otel_initialize() is True + + logger.debug(_DEBUG_MARKER) + logger.info(_INFO_MARKER) + + bodies = otlp_log_exporter.bodies + assert _INFO_MARKER in bodies + assert _DEBUG_MARKER not in bodies + + +@pytest.mark.integration +def test_otlp_log_sink_drops_records_rejected_by_log_filter(otlp_log_exporter: OtlpLogCapture) -> None: + """The OTLP log sink drops stdlib records that the service log_filter rejects.""" + logging_initialize() + assert otel_initialize(log_filter=lambda record: record["name"] != _REJECTED_LOGGER) is True + + logging.getLogger(_REJECTED_LOGGER).warning(_REJECTED_MARKER) + logging.getLogger(_KEPT_LOGGER).warning(_KEPT_MARKER) + + bodies = otlp_log_exporter.bodies + assert _KEPT_MARKER in bodies + assert _REJECTED_MARKER not in bodies From d829ad73ae32dec94a8f4fe0cbe301a8ec61dd16 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 10:02:47 +0200 Subject: [PATCH 07/12] fix(log): silence httpx and urllib3 request logs logging_initialize sets the stdlib loggers httpx, httpx2 and urllib3 to WARNING. httpx logs each request URL at INFO, including the query string of signed URLs. The root logger is at level 0, so these lines got to all loguru sinks and to Sentry breadcrumbs. Tests restore the stdlib logger levels, the root level and the InterceptHandler after each logging_initialize call. BREAKING CHANGE: logging_initialize sets the httpx, httpx2 and urllib3 loggers to WARNING. INFO request lines no longer get to the log sinks. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++ src/aignostics_foundry_core/AGENTS.md | 2 +- src/aignostics_foundry_core/log.py | 13 ++++++ tests/aignostics_foundry_core/conftest.py | 55 ++++++++++++++++++---- tests/aignostics_foundry_core/log_test.py | 56 ++++++++++++++++++++++- 5 files changed, 119 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4308ec2..bb34391 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,11 @@ Settings class: `LogSettings` | `{PREFIX}LOG_FILE_NAME` | platform log dir | Path to the log file (validated on startup when `FILE_ENABLED` is true). | | `{PREFIX}LOG_REDIRECT_LOGGING` | `true` | Redirect stdlib `logging` to loguru via `InterceptHandler`. | +`logging_initialize()` sets the stdlib loggers `httpx`, `httpx2` and `urllib3` to `WARNING`. These +HTTP clients log each request URL at `INFO`, and the URL can contain credentials in its query string +(for example, the signature of a signed URL). At `WARNING`, these request lines do not get to the log +sinks or to Sentry. The `psycopg` and `psycopg.pool` loggers are also at `WARNING`. + #### Sentry (`{PREFIX}SENTRY_`) Settings class: `SentrySettings`. Sentry is only initialised when `ENABLED=true` **and** `DSN` is diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 4546c83..410ed6e 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -171,7 +171,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Key Features**: - `InterceptHandler(logging.Handler)` — redirects stdlib log records to loguru, preserving original module/function/line metadata - `LogSettings(BaseSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}LOG_`). Fields: `level`, `stderr_enabled`, `file_enabled`, `file_name`, `redirect_logging` - - `logging_initialize(filter_func, *, context)` — removes all existing loguru handlers, then adds stderr/file handlers per settings; reads project name, version, and env file list from `context` (falls back to process-level context); embeds `project_name` and `version` in loguru `extra`; installs `InterceptHandler` for stdlib redirect; suppresses psycopg pool noise + - `logging_initialize(filter_func, *, context)` — removes all existing loguru handlers, then adds stderr/file handlers per settings; reads project name, version, and env file list from `context` (falls back to process-level context); embeds `project_name` and `version` in loguru `extra`; installs `InterceptHandler` for stdlib redirect; sets the stdlib loggers `psycopg`, `psycopg.pool`, `httpx`, `httpx2` and `urllib3` to WARNING (process-wide), so the INFO request lines of the HTTP clients, which contain the request URL and its query string, do not get to the sinks - **Location**: `aignostics_foundry_core/log.py` - **Dependencies**: `loguru>=0.7,<1`, `platformdirs>=4,<5` (mandatory) - **Import**: `from aignostics_foundry_core.log import logging_initialize, LogSettings, InterceptHandler` diff --git a/src/aignostics_foundry_core/log.py b/src/aignostics_foundry_core/log.py index c5e7a0f..6745902 100644 --- a/src/aignostics_foundry_core/log.py +++ b/src/aignostics_foundry_core/log.py @@ -7,6 +7,9 @@ - psycopg.pool: Set to WARNING level to suppress verbose INFO logs from connection pool operations (getconn/putconn). Only non-nominal pool messages (warnings and errors) are logged. +- httpx, httpx2 and urllib3: Set to WARNING level. These HTTP clients log each + request URL at INFO, and the URL can include the query string of a signed URL. + At WARNING, these request lines do not get to the log sinks or to Sentry. """ import contextlib @@ -31,6 +34,7 @@ from aignostics_foundry_core.foundry import FoundryContext _DEFAULT_PROJECT = "foundry" +_HTTP_CLIENT_LOGGERS = ("httpx", "httpx2", "urllib3") def _validate_file_name(file_name: str | None) -> str | None: @@ -186,6 +190,9 @@ def logging_initialize( handlers based on settings read from environment variables with the ``{ctx.env_prefix}LOG_`` prefix (derived from the context). + Sets the stdlib loggers ``psycopg``, ``psycopg.pool``, ``httpx``, ``httpx2`` + and ``urllib3`` to WARNING. These levels are process-wide. + Args: filter_func: Optional loguru filter callable; receives a ``Record`` and returns ``True`` to keep the message, ``False`` to drop it. @@ -234,4 +241,10 @@ def logging_initialize( logging.getLogger("psycopg.pool").setLevel(logging.WARNING) logger.trace("psycopg and psycopg.pool loggers set to WARNING to prevent deadlock and reduce noise") + # Suppress the INFO request lines of the HTTP clients. Each line contains the full request URL, + # which can include credentials in the query string (for example, the signature of a signed URL). + for http_client_logger in _HTTP_CLIENT_LOGGERS: + logging.getLogger(http_client_logger).setLevel(logging.WARNING) + logger.trace("{} loggers set to WARNING to keep request URLs out of the logs", ", ".join(_HTTP_CLIENT_LOGGERS)) + logger.trace("Logging initialized with level: {}", settings.level) diff --git a/tests/aignostics_foundry_core/conftest.py b/tests/aignostics_foundry_core/conftest.py index a0998d2..c700932 100644 --- a/tests/aignostics_foundry_core/conftest.py +++ b/tests/aignostics_foundry_core/conftest.py @@ -4,6 +4,7 @@ import logging import os +from contextlib import contextmanager from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -15,7 +16,7 @@ from tests.conftest import TEST_PROJECT_PREFIX if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Iterator from opentelemetry.sdk._logs import LoggerProvider # pyright: ignore[reportPrivateImportUsage] @@ -25,6 +26,45 @@ # otel_initialize() writes these with os.environ.setdefault(), so monkeypatch cannot undo them. _OTEL_SDK_ENV_DEFAULTS = ("OTEL_SERVICE_NAME", "OTEL_SEMCONV_STABILITY_OPT_IN", "OTEL_EXPORTER_OTLP_CERTIFICATE") +# logging_initialize() sets the level of these stdlib loggers for the whole process. +_LOGGERS_SET_BY_LOGGING_INITIALIZE = ("psycopg", "psycopg.pool", "httpx", "httpx2", "urllib3") + + +@contextmanager +def stdlib_logging_restored() -> Iterator[None]: + """Undo the process-wide logging changes of ``logging_initialize`` on exit. + + On exit, removes all loguru sinks and the ``InterceptHandler`` on the stdlib + root logger. Restores the level of the root logger and of the loggers that + ``logging_initialize`` sets to WARNING. + + Yields: + None + """ + root_logger = logging.getLogger() + saved_root_level = root_logger.level + saved_levels = {name: logging.getLogger(name).level for name in _LOGGERS_SET_BY_LOGGING_INITIALIZE} + try: + yield + finally: + logger.remove() + for handler in [h for h in root_logger.handlers if isinstance(h, InterceptHandler)]: + root_logger.removeHandler(handler) + root_logger.setLevel(saved_root_level) + for name, level in saved_levels.items(): + logging.getLogger(name).setLevel(level) + + +@pytest.fixture +def stdlib_logging_reset() -> Generator[None, None, None]: + """Undo the process-wide logging changes of ``logging_initialize`` after the test. + + Yields: + None + """ + with stdlib_logging_restored(): + yield + class OtlpLogCapture: """Access to the log records that the OTLP log sink exports.""" @@ -63,9 +103,9 @@ def otlp_log_exporter(monkeypatch: pytest.MonkeyPatch) -> Generator[OtlpLogCaptu patches only OpenTelemetry symbols, so the global ``LoggerProvider`` stays unset. - Teardown shuts the ``LoggerProvider`` down, removes all loguru sinks, removes - the ``InterceptHandler`` that ``logging_initialize`` puts on the stdlib root - logger, and restores the root logger level and the ``OTEL_*`` defaults. + Teardown shuts the ``LoggerProvider`` down, undoes the process-wide logging + changes of ``logging_initialize`` (see :func:`stdlib_logging_restored`), and + restores the ``OTEL_*`` defaults. Yields: OtlpLogCapture: Access to the exported log records. @@ -76,11 +116,10 @@ def otlp_log_exporter(monkeypatch: pytest.MonkeyPatch) -> Generator[OtlpLogCaptu monkeypatch.setenv(f"{_OTEL_PREFIX}METRICS_ENABLED", "false") monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _OTLP_ENDPOINT) saved_env = {name: os.environ.get(name) for name in _OTEL_SDK_ENV_DEFAULTS} - root_logger = logging.getLogger() - saved_root_level = root_logger.level exporter = InMemoryLogRecordExporter() with ( + stdlib_logging_restored(), patch("opentelemetry.exporter.otlp.proto.grpc._log_exporter.OTLPLogExporter", return_value=exporter), patch("opentelemetry._logs.set_logger_provider") as set_logger_provider, ): @@ -90,10 +129,6 @@ def otlp_log_exporter(monkeypatch: pytest.MonkeyPatch) -> Generator[OtlpLogCaptu finally: if capture.provider is not None: capture.provider.shutdown() - logger.remove() - for handler in [h for h in root_logger.handlers if isinstance(h, InterceptHandler)]: - root_logger.removeHandler(handler) - root_logger.setLevel(saved_root_level) for name, value in saved_env.items(): if value is None: os.environ.pop(name, None) diff --git a/tests/aignostics_foundry_core/log_test.py b/tests/aignostics_foundry_core/log_test.py index 9ce737a..b744586 100644 --- a/tests/aignostics_foundry_core/log_test.py +++ b/tests/aignostics_foundry_core/log_test.py @@ -2,17 +2,22 @@ import logging as stdlib_logging import sys +from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Any +import httpx import pytest +from loguru import logger from pydantic import ValidationError from aignostics_foundry_core.log import InterceptHandler, LogSettings, logging_initialize from tests.conftest import TEST_PROJECT_PREFIX if TYPE_CHECKING: - from loguru import Message + from collections.abc import Iterator + + from loguru import Message, Record _MARKER_MESSAGE = "log_test_unique_marker_4f2a" _STDLIB_MESSAGE = "stdlib_redirect_unique_marker_9b3c" @@ -24,10 +29,34 @@ _CONTEXTUALIZE_MARKER = "contextualize_intercept_marker_6d2e" _PARITY_NATIVE_MARKER = "parity_native_loguru_4b7a" _PARITY_STDLIB_MARKER = "parity_stdlib_intercept_9c1f" +_HTTP_CLIENT_INFO_MARKER = "http_client_info_marker_5a8d" +_HTTP_CLIENT_WARNING_MARKER = "http_client_warning_marker_2c6b" +_SIGNED_URL = "https://example.com/blob?sig=secret" +_SIGNED_URL_QUERY = "sig=secret" + + +@contextmanager +def _captured_records() -> "Iterator[list[Record]]": + """Collect every loguru record at TRACE and above while the block runs. + + Yields: + list[Record]: The records that loguru emitted inside the block. + """ + records: list[Record] = [] + + def capture(message: "Message") -> None: + records.append(message.record) + + sink_id = logger.add(capture, level="TRACE") + try: + yield records + finally: + logger.remove(sink_id) @pytest.mark.sequential @pytest.mark.unit +@pytest.mark.usefixtures("stdlib_logging_reset") class TestLoggingInitialize: """Behavioural tests for logging_initialize().""" @@ -103,6 +132,31 @@ def test_logging_initialize_suppresses_psycopg_loggers(self) -> None: assert stdlib_logging.getLogger("psycopg").level == stdlib_logging.WARNING assert stdlib_logging.getLogger("psycopg.pool").level == stdlib_logging.WARNING + @pytest.mark.parametrize("logger_name", ["httpx", "httpx2", "urllib3"]) + def test_logging_initialize_sets_http_client_loggers_to_warning(self, logger_name: str) -> None: + """After logging_initialize(), the HTTP client loggers pass WARNING records but drop INFO records.""" + logging_initialize() + + with _captured_records() as records: + stdlib_logging.getLogger(logger_name).info(_HTTP_CLIENT_INFO_MARKER) + stdlib_logging.getLogger(logger_name).warning(_HTTP_CLIENT_WARNING_MARKER) + + assert [(r["level"].name, r["message"]) for r in records if r["name"] == logger_name] == [ + ("WARNING", _HTTP_CLIENT_WARNING_MARKER) + ] + + def test_logging_initialize_drops_httpx_request_line(self) -> None: + """An httpx request writes no record, so the query string of a signed URL gets to no sink.""" + logging_initialize() + transport = httpx.MockTransport(lambda _request: httpx.Response(200)) + + with _captured_records() as records, httpx.Client(transport=transport) as client: + response = client.get(_SIGNED_URL) + + assert response.status_code == 200 + assert [r["name"] for r in records if (r["name"] or "").startswith("httpx")] == [] + assert all(_SIGNED_URL_QUERY not in r["message"] for r in records) + def test_logging_initialize_uses_context_project_name( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: From 619f6a12ac9d6a53d3fa27c68fd2aa1844f82875 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 10:07:08 +0200 Subject: [PATCH 08/12] fix(sentry): strip query strings from breadcrumbs The httpx, httpx2 and stdlib integrations parse URLs with sanitize=False and write http.query and http.fragment into span data. The SDK makes the HTTP breadcrumb from this span data, so the query string of signed URLs got to Sentry in breadcrumbs and in transaction spans. sentry_initialize now passes a before_breadcrumb hook that removes http.query, http.fragment and the query of data["url"] from HTTP breadcrumbs, and a before_send_transaction hook that does the same for the data of each span. BREAKING CHANGE: Sentry HTTP breadcrumbs and transaction spans no longer carry the query string or the fragment of the request URL. Co-Authored-By: Claude Opus 5.5 --- src/aignostics_foundry_core/AGENTS.md | 2 +- src/aignostics_foundry_core/sentry.py | 69 +++++++++++++++++- tests/aignostics_foundry_core/sentry_test.py | 74 ++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 410ed6e..4682353 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -183,7 +183,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants. - **Key Features**: - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` - - `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; suppresses noisy loggers; returns `True` on success, `False` otherwise + - `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; removes the query string and the fragment (`http.query`, `http.fragment` and the query of `url`) from HTTP breadcrumbs and from the span data of transactions, through `before_breadcrumb` and `before_send_transaction` hooks; suppresses noisy loggers; returns `True` on success, `False` otherwise - `set_sentry_user(user, role_claim)` — maps `sub` → `id`, `org_id` and the optional role claim into Sentry scope; no other claims (no email, name or other personal data); pass `None` to clear context; no-op when `sentry_sdk` is absent - **Location**: `aignostics_foundry_core/sentry.py` - **Dependencies**: `sentry-sdk>=2,<3` (mandatory); `loguru>=0.7,<1` diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index 22385d4..de5621d 100644 --- a/src/aignostics_foundry_core/sentry.py +++ b/src/aignostics_foundry_core/sentry.py @@ -3,7 +3,7 @@ import re import urllib.parse from importlib.util import find_spec -from typing import TYPE_CHECKING, Annotated, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal, cast from loguru import logger from pydantic import AfterValidator, BeforeValidator, Field, PlainSerializer, SecretStr @@ -13,6 +13,7 @@ from aignostics_foundry_core.settings import OpaqueSettings, strip_to_none_before_validator if TYPE_CHECKING: + from sentry_sdk._types import Event from sentry_sdk.integrations import Integration from aignostics_foundry_core.foundry import FoundryContext @@ -23,6 +24,10 @@ _ERR_MSG_INVALID_DOMAIN = "Sentry DSN must use a valid Sentry domain (ingest.us.sentry.io or ingest.de.sentry.io)" _ERR_MSG_INVALID_FORMAT = "Invalid Sentry DSN format" _VALID_SENTRY_DOMAIN_PATTERN = r"^[a-f0-9]+@o\d+\.ingest\.(us|de)\.sentry\.io$" +_HTTP_BREADCRUMB_TYPE = "http" +_HTTP_BREADCRUMB_CATEGORY = "httplib" +_URL_DATA_KEY = "url" +_URL_PART_DATA_KEYS = frozenset({"http.query", "http.fragment"}) def _validate_url_scheme(parsed_url: urllib.parse.ParseResult) -> None: @@ -115,6 +120,63 @@ def _validate_https_dsn(value: SecretStr | None) -> SecretStr | None: return value +def _without_url_query(data: dict[str, Any]) -> dict[str, Any]: + """Return a copy of HTTP breadcrumb or span *data* without the query string and the fragment. + + Removes the ``http.query`` and ``http.fragment`` keys, and the query and the fragment of + ``data["url"]``. Query strings can hold credentials, for example the signature of a signed URL. + The result is a copy because the SDK makes the HTTP breadcrumb from the span data dict itself. + + Args: + data: The ``data`` of a breadcrumb or a span. + + Returns: + dict[str, Any]: A copy of *data* without the query string and the fragment. + """ + stripped = {key: value for key, value in data.items() if key not in _URL_PART_DATA_KEYS} + url = stripped.get(_URL_DATA_KEY) + if isinstance(url, str): + stripped[_URL_DATA_KEY] = urllib.parse.urlsplit(url)._replace(query="", fragment="").geturl() + return stripped + + +def _before_breadcrumb(crumb: dict[str, Any], _hint: dict[str, Any]) -> dict[str, Any]: + """Remove the query string and the fragment from HTTP breadcrumbs. Return other breadcrumbs unchanged. + + Args: + crumb: The breadcrumb that the SDK is about to record. + _hint: The breadcrumb hint (unused). + + Returns: + dict[str, Any]: The breadcrumb to record. + """ + is_http = crumb.get("type") == _HTTP_BREADCRUMB_TYPE or crumb.get("category") == _HTTP_BREADCRUMB_CATEGORY + data = crumb.get("data") + if is_http and isinstance(data, dict): + crumb["data"] = _without_url_query(cast("dict[str, Any]", data)) + return crumb + + +def _before_send_transaction(event: "Event", _hint: dict[str, Any]) -> "Event": + """Remove the query string and the fragment from the data of each span of a transaction. + + Args: + event: The transaction event that the SDK is about to send. + _hint: The event hint (unused). + + Returns: + Event: The transaction event to send. + """ + spans = event.get("spans") + if not isinstance(spans, list): + return event + for span in spans: + data = span.get("data") + if isinstance(data, dict): + span["data"] = _without_url_query(cast("dict[str, Any]", data)) + return event + + class SentrySettings(OpaqueSettings): """Configuration settings for Sentry integration. @@ -258,6 +320,9 @@ def sentry_initialize( All project-specific metadata is derived from *context* (or the global context installed via :func:`~aignostics_foundry_core.foundry.set_context`). + HTTP breadcrumbs and the spans of transactions carry no query string and no fragment. + Both can hold credentials, for example the signature of a signed URL. + Args: integrations: List of Sentry SDK integrations to register, or ``None``. context: :class:`~aignostics_foundry_core.foundry.FoundryContext` providing @@ -296,6 +361,8 @@ def sentry_initialize( profile_session_sample_rate=settings.profile_session_sample_rate, profile_lifecycle=settings.profile_lifecycle, enable_logs=settings.enable_logs, + before_breadcrumb=_before_breadcrumb, + before_send_transaction=_before_send_transaction, integrations=integrations if integrations is not None else [], ) sentry_sdk.set_context( diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index f6dacfd..ea4d8d4 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any from unittest.mock import patch +import httpx import pytest import sentry_sdk from fastapi import FastAPI @@ -45,6 +46,12 @@ _SECRET_LOCAL_NAME = "client_secret" # ruff: ignore[hardcoded-password-string] _SECRET_LOCAL_VALUE = "s3cr3t" # ruff: ignore[hardcoded-password-string] _FAILING_ROUTE = "/fail" +_SIGNED_URL_QUERY = "sig=secret" +_SIGNED_URL_MARKER = "sig=" +_SIGNED_BLOB_URL = f"https://storage.example.com/blob?{_SIGNED_URL_QUERY}" +_HTTP_QUERY_KEY = "http.query" +_HTTP_FRAGMENT_KEY = "http.fragment" +_HTTPLIB_CATEGORY = "httplib" class _CapturingTransport(Transport): @@ -142,6 +149,17 @@ def fail() -> None: # pyright: ignore[reportUnusedFunction] TestClient(app, raise_server_exceptions=False).post(_FAILING_ROUTE, json={_SECRET_LOCAL_NAME: _SECRET_LOCAL_VALUE}) +def _get_signed_blob_url() -> None: + """Send a GET with a signed query string through an :class:`httpx.Client` on a mock transport.""" + with httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: + client.get(_SIGNED_BLOB_URL) + + +def _breadcrumbs(event: dict[str, Any], category: str) -> list[dict[str, Any]]: + """Return the breadcrumbs of *event* that have *category*.""" + return [crumb for crumb in event["breadcrumbs"]["values"] if crumb.get("category") == category] + + @pytest.fixture def sentry_capture(monkeypatch: pytest.MonkeyPatch) -> Generator[SentryCapture, None, None]: """Enable Sentry with a valid DSN and capture all envelopes in memory. @@ -282,6 +300,62 @@ def test_failed_fastapi_request_event_has_request_data_when_always( assert event["request"]["data"][_SECRET_LOCAL_NAME] == _SECRET_LOCAL_VALUE +@pytest.mark.integration +class TestSentryHttpData: + """Tests for the URL data that HTTP breadcrumbs and spans carry to Sentry.""" + + def test_http_breadcrumb_has_no_query_string(self, sentry_capture: SentryCapture) -> None: + """The ``httplib`` breadcrumb of an httpx request carries no query string.""" + sentry_capture.start() + _get_signed_blob_url() + sentry_sdk.capture_message(_PROBE_MESSAGE) + + (event,) = sentry_capture.items("event") + (crumb,) = _breadcrumbs(event, _HTTPLIB_CATEGORY) + assert _HTTP_QUERY_KEY not in crumb["data"] + assert _SIGNED_URL_MARKER not in crumb["data"]["url"] + + def test_manual_http_breadcrumb_with_query_in_url_is_stripped(self, sentry_capture: SentryCapture) -> None: + """An HTTP breadcrumb added by hand loses the query keys and the query of its URL.""" + sentry_capture.start() + sentry_sdk.add_breadcrumb( + type="http", + category=_HTTPLIB_CATEGORY, + data={"url": _SIGNED_BLOB_URL + "#part", _HTTP_QUERY_KEY: _SIGNED_URL_QUERY, _HTTP_FRAGMENT_KEY: "part"}, + ) + sentry_sdk.capture_message(_PROBE_MESSAGE) + + (event,) = sentry_capture.items("event") + (crumb,) = _breadcrumbs(event, _HTTPLIB_CATEGORY) + assert crumb["data"] == {"url": "https://storage.example.com/blob"} + + def test_non_http_breadcrumb_is_unchanged(self, sentry_capture: SentryCapture) -> None: + """A breadcrumb of another type and category keeps its data, also URL-like keys.""" + data = {"url": _SIGNED_BLOB_URL, _HTTP_QUERY_KEY: _SIGNED_URL_QUERY} + sentry_capture.start() + sentry_sdk.add_breadcrumb(type="default", category="app", data=data) + sentry_sdk.capture_message(_PROBE_MESSAGE) + + (event,) = sentry_capture.items("event") + (crumb,) = _breadcrumbs(event, "app") + assert crumb["data"] == data + + def test_transaction_span_has_no_query_string( + self, sentry_capture: SentryCapture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The HTTP client span of an httpx request inside a transaction carries no query string.""" + monkeypatch.setenv(f"{_SENTRY_PREFIX}TRACES_SAMPLE_RATE", "1.0") + sentry_capture.start() + with sentry_sdk.start_transaction(name=_PROBE_MESSAGE): + _get_signed_blob_url() + + (transaction,) = sentry_capture.items("transaction") + http_spans = [span for span in transaction["spans"] if span["op"] == "http.client"] + assert http_spans + assert all(_HTTP_QUERY_KEY not in span["data"] for span in http_spans) + assert _SIGNED_URL_MARKER not in json.dumps(transaction) + + @pytest.mark.integration class TestSentrySettingsDsnValidation: """Tests for SentrySettings DSN edge-case validation paths.""" From aff567cedee86d09b2e3dce5a4413bc410eb9255 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 10:12:26 +0200 Subject: [PATCH 09/12] fix(sentry): send trace headers to allowed hosts Add SentrySettings.trace_propagation_targets with the default [] and pass it to sentry_sdk.init. The SDK default [".*"] adds sentry-trace and baggage to every outbound request inside a transaction, also to partner systems. The baggage header carries the release, the environment and the public key of the DSN. BREAKING CHANGE: Sentry no longer adds sentry-trace and baggage headers to outbound requests. Set {PREFIX}SENTRY_TRACE_PROPAGATION_TARGETS to a JSON list of host regexes to opt in. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + src/aignostics_foundry_core/AGENTS.md | 2 +- src/aignostics_foundry_core/sentry.py | 15 ++++ tests/aignostics_foundry_core/sentry_test.py | 92 +++++++++++++++++++- 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bb34391..1ee8436 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ set. | `{PREFIX}SENTRY_SEND_DEFAULT_PII` | `false` | Include personally-identifiable information in events. | | `{PREFIX}SENTRY_INCLUDE_LOCAL_VARIABLES` | `false` | Include the local variables of each stack frame in error events. Locals can contain credentials and personal data. | | `{PREFIX}SENTRY_MAX_REQUEST_BODY_SIZE` | `"never"` | Maximum size of HTTP request bodies in error events: `"never"`, `"small"` (up to 1 KB), `"medium"` (up to 10 KB) or `"always"`. Request bodies can contain credentials and personal data. The FastAPI integration sends JSON bodies even when `SEND_DEFAULT_PII` is `false`. | +| `{PREFIX}SENTRY_TRACE_PROPAGATION_TARGETS` | `[]` | JSON list of regexes. Sentry adds the `sentry-trace` and `baggage` headers only to outbound requests whose URL matches one of them. The `baggage` header contains the release, the environment and the public key of the DSN. With the default, no outbound request gets these headers. Example: `{PREFIX}SENTRY_TRACE_PROPAGATION_TARGETS='["internal\\.example\\.com"]'`. | | `{PREFIX}SENTRY_MAX_BREADCRUMBS` | `50` | Maximum breadcrumbs stored per event. | | `{PREFIX}SENTRY_SAMPLE_RATE` | `1.0` | Error event sample rate (0.0–1.0). | | `{PREFIX}SENTRY_TRACES_SAMPLE_RATE` | `0.1` | Transaction/trace sample rate. | diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 4682353..63d3a3a 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -182,7 +182,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants. - **Key Features**: - - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` + - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `trace_propagation_targets` (`list[str]` of URL regexes, default `[]`; no outbound request gets `sentry-trace` or `baggage` headers; the env var takes a JSON list), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` - `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; removes the query string and the fragment (`http.query`, `http.fragment` and the query of `url`) from HTTP breadcrumbs and from the span data of transactions, through `before_breadcrumb` and `before_send_transaction` hooks; suppresses noisy loggers; returns `True` on success, `False` otherwise - `set_sentry_user(user, role_claim)` — maps `sub` → `id`, `org_id` and the optional role claim into Sentry scope; no other claims (no email, name or other personal data); pass `None` to clear context; no-op when `sentry_sdk` is absent - **Location**: `aignostics_foundry_core/sentry.py` diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index de5621d..28ec07b 100644 --- a/src/aignostics_foundry_core/sentry.py +++ b/src/aignostics_foundry_core/sentry.py @@ -248,6 +248,20 @@ def __init__(self, **kwargs: Any) -> None: # ruff: ignore[any-type] ), ] + trace_propagation_targets: Annotated[ + list[str], + Field( + description=( + "Regexes of the outbound request URLs that get the sentry-trace and baggage headers. " + "Empty by default, so no outbound request gets them: the baggage header carries the " + "release, the environment and the public key of the DSN. The env var takes a JSON list " + "(https://docs.sentry.io/platforms/python/configuration/options/#trace-propagation-targets)" + ), + examples=[[r"internal\.example\.com"]], + default_factory=list, + ), + ] + max_breadcrumbs: Annotated[ int, Field( @@ -355,6 +369,7 @@ def sentry_initialize( send_default_pii=settings.send_default_pii, include_local_variables=settings.include_local_variables, max_request_body_size=settings.max_request_body_size, + trace_propagation_targets=settings.trace_propagation_targets, sample_rate=settings.sample_rate, traces_sample_rate=settings.traces_sample_rate, profiles_sample_rate=settings.profiles_sample_rate, diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index ea4d8d4..210b728 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -1,11 +1,12 @@ """Tests for aignostics_foundry_core.sentry.""" import json -from collections.abc import Generator +from collections.abc import Callable, Generator from typing import TYPE_CHECKING, Any from unittest.mock import patch import httpx +import httpx2 import pytest import sentry_sdk from fastapi import FastAPI @@ -52,6 +53,12 @@ _HTTP_QUERY_KEY = "http.query" _HTTP_FRAGMENT_KEY = "http.fragment" _HTTPLIB_CATEGORY = "httplib" +# The env var form that README.md documents: a JSON list with one regex, internal\.example\.com +_INTERNAL_TRACE_TARGETS_JSON = r'["internal\\.example\\.com"]' +_INTERNAL_URL = "https://internal.example.com/" +_PARTNER_URL = "https://partner.example.org/" +_SENTRY_TRACE_HEADER = "sentry-trace" +_BAGGAGE_HEADER = "baggage" class _CapturingTransport(Transport): @@ -155,6 +162,54 @@ def _get_signed_blob_url() -> None: client.get(_SIGNED_BLOB_URL) +def _httpx_request_headers(url: str) -> dict[str, str]: + """Send a GET to *url* through an :class:`httpx.Client` on a mock transport, inside a Sentry transaction. + + The Sentry httpx integration adds trace headers only to requests inside a transaction, as in a + request handler of a service. + + Returns: + dict[str, str]: The headers of the request that reached the transport. + """ + sent: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + sent.append(request) + return httpx.Response(200) + + with ( + sentry_sdk.start_transaction(name=_PROBE_MESSAGE), + httpx.Client(transport=httpx.MockTransport(handle)) as client, + ): + client.get(url) + (request,) = sent + return dict(request.headers) + + +def _httpx2_request_headers(url: str) -> dict[str, str]: + """Send a GET to *url* through an :class:`httpx2.Client` on a mock transport, inside a Sentry transaction. + + The Sentry httpx2 integration adds trace headers only to requests inside a transaction, as in a + request handler of a service. + + Returns: + dict[str, str]: The headers of the request that reached the transport. + """ + sent: list[httpx2.Request] = [] + + def handle(request: httpx2.Request) -> httpx2.Response: + sent.append(request) + return httpx2.Response(200) + + with ( + sentry_sdk.start_transaction(name=_PROBE_MESSAGE), + httpx2.Client(transport=httpx2.MockTransport(handle)) as client, + ): + client.get(url) + (request,) = sent + return dict(request.headers) + + def _breadcrumbs(event: dict[str, Any], category: str) -> list[dict[str, Any]]: """Return the breadcrumbs of *event* that have *category*.""" return [crumb for crumb in event["breadcrumbs"]["values"] if crumb.get("category") == category] @@ -355,6 +410,41 @@ def test_transaction_span_has_no_query_string( assert all(_HTTP_QUERY_KEY not in span["data"] for span in http_spans) assert _SIGNED_URL_MARKER not in json.dumps(transaction) + @pytest.mark.parametrize("send_get", [_httpx_request_headers, _httpx2_request_headers], ids=["httpx", "httpx2"]) + def test_outbound_request_has_no_trace_headers_by_default( + self, sentry_capture: SentryCapture, send_get: Callable[[str], dict[str, str]] + ) -> None: + """An outbound request gets no ``sentry-trace`` or ``baggage`` header at default settings.""" + sentry_capture.start() + + headers = send_get(_PARTNER_URL) + + assert _SENTRY_TRACE_HEADER not in headers + assert _BAGGAGE_HEADER not in headers + + def test_outbound_request_to_allowed_host_has_trace_headers( + self, sentry_capture: SentryCapture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A request to a host in TRACE_PROPAGATION_TARGETS gets the ``sentry-trace`` header.""" + monkeypatch.setenv(f"{_SENTRY_PREFIX}TRACE_PROPAGATION_TARGETS", _INTERNAL_TRACE_TARGETS_JSON) + sentry_capture.start() + + headers = _httpx_request_headers(_INTERNAL_URL) + + assert _SENTRY_TRACE_HEADER in headers + + def test_outbound_request_to_other_host_has_no_trace_headers_when_targets_set( + self, sentry_capture: SentryCapture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A request to a host outside TRACE_PROPAGATION_TARGETS gets no ``sentry-trace`` or ``baggage`` header.""" + monkeypatch.setenv(f"{_SENTRY_PREFIX}TRACE_PROPAGATION_TARGETS", _INTERNAL_TRACE_TARGETS_JSON) + sentry_capture.start() + + headers = _httpx_request_headers(_PARTNER_URL) + + assert _SENTRY_TRACE_HEADER not in headers + assert _BAGGAGE_HEADER not in headers + @pytest.mark.integration class TestSentrySettingsDsnValidation: From e10a1907fa98d974e5ee4f2e9c6017ce293837b1 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 10:15:39 +0200 Subject: [PATCH 10/12] fix(sentry): disable Sentry logs by default SentrySettings.enable_logs now defaults to False. On sentry-sdk 2.67.1, the old default sent every INFO+ record from the stdlib logging module and from loguru to Sentry Logs. sentry-sdk 2.68.0 and later ignore enable_logs; capture_sentry_logs=True on LoggingIntegration or LoguruIntegration replaces it. BREAKING CHANGE: {PREFIX}SENTRY_ENABLE_LOGS defaults to false. Log records no longer go to Sentry Logs unless a service opts in. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/aignostics_foundry_core/AGENTS.md | 2 +- src/aignostics_foundry_core/sentry.py | 9 +++++++-- tests/aignostics_foundry_core/sentry_test.py | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1ee8436..ef33e35 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ set. | `{PREFIX}SENTRY_PROFILES_SAMPLE_RATE` | `0.1` | Profiler sample rate. | | `{PREFIX}SENTRY_PROFILE_SESSION_SAMPLE_RATE` | `0.1` | Profile session sample rate. | | `{PREFIX}SENTRY_PROFILE_LIFECYCLE` | `"trace"` | Profile lifecycle mode: `"trace"` or `"manual"`. | -| `{PREFIX}SENTRY_ENABLE_LOGS` | `true` | Forward log records to Sentry. | +| `{PREFIX}SENTRY_ENABLE_LOGS` | `false` | Send log records to Sentry Logs. Log records can contain credentials and personal data. sentry-sdk 2.68.0 and later ignore this option. With these versions, set `capture_sentry_logs=True` on `LoggingIntegration` or `LoguruIntegration` and pass the integration to `boot(sentry_integrations=...)`. | #### OpenTelemetry (`{PREFIX}OTEL_`) diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 63d3a3a..6c21c57 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -182,7 +182,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants. - **Key Features**: - - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `trace_propagation_targets` (`list[str]` of URL regexes, default `[]`; no outbound request gets `sentry-trace` or `baggage` headers; the env var takes a JSON list), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` + - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `trace_propagation_targets` (`list[str]` of URL regexes, default `[]`; no outbound request gets `sentry-trace` or `baggage` headers; the env var takes a JSON list), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` (default `False`; no log records go to Sentry Logs; sentry-sdk 2.68.0 and later ignore it and use `capture_sentry_logs=True` on `LoggingIntegration` or `LoguruIntegration`) - `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; removes the query string and the fragment (`http.query`, `http.fragment` and the query of `url`) from HTTP breadcrumbs and from the span data of transactions, through `before_breadcrumb` and `before_send_transaction` hooks; suppresses noisy loggers; returns `True` on success, `False` otherwise - `set_sentry_user(user, role_claim)` — maps `sub` → `id`, `org_id` and the optional role claim into Sentry scope; no other claims (no email, name or other personal data); pass `None` to clear context; no-op when `sentry_sdk` is absent - **Location**: `aignostics_foundry_core/sentry.py` diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index 28ec07b..90ab62d 100644 --- a/src/aignostics_foundry_core/sentry.py +++ b/src/aignostics_foundry_core/sentry.py @@ -318,8 +318,13 @@ def __init__(self, **kwargs: Any) -> None: # ruff: ignore[any-type] enable_logs: Annotated[ bool, Field( - description="Enable Sentry log integration (https://docs.sentry.io/platforms/python/logging/)", - default=True, + description=( + "Send log records to Sentry Logs (https://docs.sentry.io/platforms/python/logs/). " + "Off by default, because log records can contain credentials and personal data. " + "sentry-sdk 2.68.0 and later ignore this option: set capture_sentry_logs=True on " + "LoggingIntegration or LoguruIntegration instead." + ), + default=False, ), ] diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 210b728..87a43c4 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -1,6 +1,7 @@ """Tests for aignostics_foundry_core.sentry.""" import json +import logging from collections.abc import Callable, Generator from typing import TYPE_CHECKING, Any from unittest.mock import patch @@ -11,6 +12,7 @@ import sentry_sdk from fastapi import FastAPI from fastapi.testclient import TestClient +from loguru import logger from pydantic import ValidationError from sentry_sdk.client import NonRecordingClient from sentry_sdk.transport import Transport @@ -59,6 +61,8 @@ _PARTNER_URL = "https://partner.example.org/" _SENTRY_TRACE_HEADER = "sentry-trace" _BAGGAGE_HEADER = "baggage" +_APP_LOGGER_NAME = "app" +_LOG_ITEM_TYPE = "log" class _CapturingTransport(Transport): @@ -354,6 +358,21 @@ def test_failed_fastapi_request_event_has_request_data_when_always( (event,) = sentry_capture.items("event") assert event["request"]["data"][_SECRET_LOCAL_NAME] == _SECRET_LOCAL_VALUE + def test_sentry_logs_not_sent_by_default( + self, sentry_capture: SentryCapture, caplog: pytest.LogCaptureFixture + ) -> None: + """No INFO record from the stdlib logging module or from loguru goes to Sentry Logs at default settings. + + The ``app`` logger is set to INFO, so that the record gets to the handlers of the Sentry + logging integration. + """ + caplog.set_level(logging.INFO, logger=_APP_LOGGER_NAME) + sentry_capture.start() + logging.getLogger(_APP_LOGGER_NAME).info("stdlib record for %s", _PROBE_MESSAGE) + logger.info("loguru record for {}", _PROBE_MESSAGE) + + assert sentry_capture.items(_LOG_ITEM_TYPE) == [] + @pytest.mark.integration class TestSentryHttpData: From fd222f1f59115ba0b9a1be91386f472f873c9388 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 10:25:06 +0200 Subject: [PATCH 11/12] chore(deps): require sentry-sdk>=2.68.0 sentry-sdk 2.68.0 moved Sentry Logs from the global enable_logs option to capture_sentry_logs on LoggingIntegration and LoguruIntegration. With this lower bound, foundry-core supports one Sentry Logs mechanism only. The lock file moves from 2.67.1 to 2.70.0. Unit tests pass with --resolution lowest-direct (sentry-sdk 2.68.0), and the Sentry, log, OTel and boot integration tests pass on 2.68.0 and on 2.70.0. BREAKING CHANGE: foundry-core requires sentry-sdk 2.68.0 or later. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a445733..80398ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ dependencies = [ "pydantic-settings>=2,<3", "python-dotenv>=1,<2", "rich>=15,<16", - "sentry-sdk>=2,<3", + "sentry-sdk>=2.68.0,<3", "sqlalchemy[postgresql-psycopgbinary,asyncio]>=2.0.31,<3", # >=2.0.31: first version with postgresql-psycopgbinary extra "chancy[cron]>=0.25.1,<1", "nicegui>=3,<4", diff --git a/uv.lock b/uv.lock index 54d161a..d66bdf8 100644 --- a/uv.lock +++ b/uv.lock @@ -105,7 +105,7 @@ requires-dist = [ { name = "pyjwt", specifier = ">=2.10,<3" }, { name = "python-dotenv", specifier = ">=1,<2" }, { name = "rich", specifier = ">=15,<16" }, - { name = "sentry-sdk", specifier = ">=2,<3" }, + { name = "sentry-sdk", specifier = ">=2.68.0,<3" }, { name = "sqlalchemy", extras = ["postgresql-psycopgbinary", "asyncio"], specifier = ">=2.0.31,<3" }, { name = "starlette", specifier = ">=1.3.1" }, { name = "truststore", specifier = ">=0.9,<1" }, @@ -3528,15 +3528,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.67.1" +version = "2.70.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/8a/b2eec40df8a67bf073e244d29001d04ee365d163bc4f15efdfce35f53090/sentry_sdk-2.67.1.tar.gz", hash = "sha256:f263d8c9aa4137750640de8fb0ed5404df6bb564e20e4b59cb16a6eeba18d4ed", size = 990599, upload-time = "2026-08-10T13:05:55.892Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/0a/e37553f3106d0f7f0d4b8ad2c7b92410d131929786ffd116833d3b8164df/sentry_sdk-2.70.0.tar.gz", hash = "sha256:27e4b512f47be93136645dbe8f02d430473ee73a389ce430b2b7104baa66cf75", size = 1047234, upload-time = "2026-09-22T09:40:11.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/e2/70692eba662037cddf93391cbbf98297159f3038612e9b9a8129e16feb7a/sentry_sdk-2.67.1-py3-none-any.whl", hash = "sha256:a66bfbce1cd8a93c51c369d642ad85b46253ea7a6f7938141315b83e2823cda5", size = 515591, upload-time = "2026-08-10T13:05:54.213Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/b1e56f009bfb9b8023910b533d5ebf4ec5650da49891f2b8c7f3d4815401/sentry_sdk-2.70.0-py3-none-any.whl", hash = "sha256:89600e8bb5fc5239cad5c4434217f453204f2207ad29399ea58947460b37a62e", size = 531752, upload-time = "2026-09-22T09:40:10.063Z" }, ] [[package]] From acfee177fe2acce527cec6fab66c47aa06016f3d Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 25 Sep 2026 10:27:12 +0200 Subject: [PATCH 12/12] fix(sentry): remove the enable_logs setting sentry-sdk 2.68.0 and later ignore the enable_logs option and write a deprecation warning for it. foundry-core now requires 2.68.0, so the setting has no effect. Remove SentrySettings.enable_logs and stop passing it to sentry_sdk.init. A service opts in to Sentry Logs with LoggingIntegration(capture_sentry_logs=True) or LoguruIntegration(capture_sentry_logs=True) in boot(sentry_integrations=...). SentrySettings uses extra="ignore", so a service that still sets {PREFIX}SENTRY_ENABLE_LOGS starts as before. BREAKING CHANGE: SentrySettings has no enable_logs field, and {PREFIX}SENTRY_ENABLE_LOGS has no effect. Pass LoggingIntegration(capture_sentry_logs=True) or LoguruIntegration(capture_sentry_logs=True) to send log records to Sentry Logs. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++- src/aignostics_foundry_core/AGENTS.md | 4 ++-- src/aignostics_foundry_core/sentry.py | 14 ----------- tests/aignostics_foundry_core/sentry_test.py | 25 ++++++++++++++++++++ 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ef33e35..edd2735 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,11 @@ set. | `{PREFIX}SENTRY_PROFILES_SAMPLE_RATE` | `0.1` | Profiler sample rate. | | `{PREFIX}SENTRY_PROFILE_SESSION_SAMPLE_RATE` | `0.1` | Profile session sample rate. | | `{PREFIX}SENTRY_PROFILE_LIFECYCLE` | `"trace"` | Profile lifecycle mode: `"trace"` or `"manual"`. | -| `{PREFIX}SENTRY_ENABLE_LOGS` | `false` | Send log records to Sentry Logs. Log records can contain credentials and personal data. sentry-sdk 2.68.0 and later ignore this option. With these versions, set `capture_sentry_logs=True` on `LoggingIntegration` or `LoguruIntegration` and pass the integration to `boot(sentry_integrations=...)`. | + +Log records do not go to Sentry Logs, because they can contain credentials and personal data. +To send them, pass `LoggingIntegration(capture_sentry_logs=True)` or +`LoguruIntegration(capture_sentry_logs=True)` to `boot(sentry_integrations=...)`. foundry-core +requires sentry-sdk 2.68.0 or later, and it has no `{PREFIX}SENTRY_ENABLE_LOGS` setting. #### OpenTelemetry (`{PREFIX}OTEL_`) diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 6c21c57..1e257d9 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -182,11 +182,11 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants. - **Key Features**: - - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `trace_propagation_targets` (`list[str]` of URL regexes, default `[]`; no outbound request gets `sentry-trace` or `baggage` headers; the env var takes a JSON list), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` (default `False`; no log records go to Sentry Logs; sentry-sdk 2.68.0 and later ignore it and use `capture_sentry_logs=True` on `LoggingIntegration` or `LoguruIntegration`) + - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `trace_propagation_targets` (`list[str]` of URL regexes, default `[]`; no outbound request gets `sentry-trace` or `baggage` headers; the env var takes a JSON list), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`. There is no `enable_logs` field: log records go to Sentry Logs only when a service passes `LoggingIntegration(capture_sentry_logs=True)` or `LoguruIntegration(capture_sentry_logs=True)` (sentry-sdk>=2.68.0) - `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; removes the query string and the fragment (`http.query`, `http.fragment` and the query of `url`) from HTTP breadcrumbs and from the span data of transactions, through `before_breadcrumb` and `before_send_transaction` hooks; suppresses noisy loggers; returns `True` on success, `False` otherwise - `set_sentry_user(user, role_claim)` — maps `sub` → `id`, `org_id` and the optional role claim into Sentry scope; no other claims (no email, name or other personal data); pass `None` to clear context; no-op when `sentry_sdk` is absent - **Location**: `aignostics_foundry_core/sentry.py` -- **Dependencies**: `sentry-sdk>=2,<3` (mandatory); `loguru>=0.7,<1` +- **Dependencies**: `sentry-sdk>=2.68.0,<3` (mandatory); `loguru>=0.7,<1` - **Import**: `from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user` ### otel diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index 90ab62d..dea2d6c 100644 --- a/src/aignostics_foundry_core/sentry.py +++ b/src/aignostics_foundry_core/sentry.py @@ -315,19 +315,6 @@ def __init__(self, **kwargs: Any) -> None: # ruff: ignore[any-type] ), ] - enable_logs: Annotated[ - bool, - Field( - description=( - "Send log records to Sentry Logs (https://docs.sentry.io/platforms/python/logs/). " - "Off by default, because log records can contain credentials and personal data. " - "sentry-sdk 2.68.0 and later ignore this option: set capture_sentry_logs=True on " - "LoggingIntegration or LoguruIntegration instead." - ), - default=False, - ), - ] - def sentry_initialize( integrations: "list[Integration] | None", @@ -380,7 +367,6 @@ def sentry_initialize( profiles_sample_rate=settings.profiles_sample_rate, profile_session_sample_rate=settings.profile_session_sample_rate, profile_lifecycle=settings.profile_lifecycle, - enable_logs=settings.enable_logs, before_breadcrumb=_before_breadcrumb, before_send_transaction=_before_send_transaction, integrations=integrations if integrations is not None else [], diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 87a43c4..7b0c52a 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -15,6 +15,7 @@ from loguru import logger from pydantic import ValidationError from sentry_sdk.client import NonRecordingClient +from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.transport import Transport from aignostics_foundry_core.foundry import set_context @@ -373,6 +374,30 @@ def test_sentry_logs_not_sent_by_default( assert sentry_capture.items(_LOG_ITEM_TYPE) == [] + def test_sentry_logs_not_sent_when_enable_logs_env_var_is_set( + self, sentry_capture: SentryCapture, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A service that still sets the removed ENABLE_LOGS env var starts Sentry and sends no Sentry Logs.""" + monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLE_LOGS", "true") + caplog.set_level(logging.INFO, logger=_APP_LOGGER_NAME) + + assert sentry_capture.start() is True + logging.getLogger(_APP_LOGGER_NAME).info("stdlib record for %s", _PROBE_MESSAGE) + logger.info("loguru record for {}", _PROBE_MESSAGE) + + assert sentry_capture.items(_LOG_ITEM_TYPE) == [] + + def test_sentry_logs_sent_when_logging_integration_captures_them( + self, sentry_capture: SentryCapture, caplog: pytest.LogCaptureFixture + ) -> None: + """A service opts in to Sentry Logs with LoggingIntegration(capture_sentry_logs=True).""" + caplog.set_level(logging.INFO, logger=_APP_LOGGER_NAME) + sentry_capture.start([LoggingIntegration(capture_sentry_logs=True)]) + logging.getLogger(_APP_LOGGER_NAME).info("stdlib record for %s", _PROBE_MESSAGE) + + bodies = [log["body"] for batch in sentry_capture.items(_LOG_ITEM_TYPE) for log in batch["items"]] + assert f"stdlib record for {_PROBE_MESSAGE}" in bodies + @pytest.mark.integration class TestSentryHttpData: