diff --git a/README.md b/README.md index fc75964..edd2735 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 @@ -155,13 +160,20 @@ 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_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. | | `{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. | + +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_`) @@ -178,6 +190,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/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/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index bf44aec..1e257d9 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` @@ -169,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` @@ -180,13 +182,27 @@ 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` - - `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 + - `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 + +**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/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/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/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index a7cce24..dea2d6c 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. @@ -161,6 +223,45 @@ 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_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", + ), + ] + + 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( @@ -214,14 +315,6 @@ 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, - ), - ] - def sentry_initialize( integrations: "list[Integration] | None", @@ -233,6 +326,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 @@ -263,12 +359,16 @@ 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, + 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, 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( @@ -304,15 +404,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"): @@ -324,18 +430,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/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..c700932 --- /dev/null +++ b/tests/aignostics_foundry_core/conftest.py @@ -0,0 +1,136 @@ +"""Shared fixtures for the aignostics_foundry_core test modules.""" + +from __future__ import annotations + +import logging +import os +from contextlib import contextmanager +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, Iterator + + 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") + +# 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.""" + + 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, 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. + """ + 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} + + 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, + ): + capture = OtlpLogCapture(exporter, set_logger_provider) + try: + yield capture + finally: + if capture.provider is not None: + capture.provider.shutdown() + 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/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: 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 diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 899c02b..7b0c52a 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -1,21 +1,248 @@ """Tests for aignostics_foundry_core.sentry.""" -from unittest.mock import MagicMock, patch - +import json +import logging +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 +from fastapi.testclient import TestClient +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 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" +_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_" -_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"} +_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" +# 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" +_APP_LOGGER_NAME = "app" +_LOG_ITEM_TYPE = "log" + + +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 + ] + + +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"]] + + +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}) + + +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 _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] + + +@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 +263,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 +279,215 @@ 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 +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"] + + 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``. + 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.items("event") + assert "request" in event + assert not event["request"].get("data") + 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 + ) -> 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.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) == [] + + 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: + """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.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 @@ -159,52 +550,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) - 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 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) + + (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" 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]]