From 92901233caf7a431b17297e4bc179977e0f4f7da Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Thu, 26 Mar 2026 16:18:22 +0100 Subject: [PATCH 01/11] feat(sentry): add configurable sentry_initialize and SentrySettings Co-Authored-By: Claude Sonnet 4.6 --- ATTRIBUTIONS.md | 36 +- pyproject.toml | 1 + src/aignostics_foundry_core/AGENTS.md | 14 + src/aignostics_foundry_core/sentry.py | 360 +++++++++++++++++++ tests/aignostics_foundry_core/sentry_test.py | 100 ++++++ uv.lock | 15 + 6 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 src/aignostics_foundry_core/sentry.py create mode 100644 tests/aignostics_foundry_core/sentry_test.py diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index b258568..a096d77 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -252,7 +252,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -## aignostics-foundry-core (0.0.0) - MIT License +## aignostics-foundry-core (0.1.0) - MIT License 🏭 Foundational infrastructure for Foundry components. @@ -7176,6 +7176,40 @@ are: ``` +## sentry-sdk (2.56.0) - BSD License + +Python client for Sentry (https://sentry.io) + +* URL: https://github.com/getsentry/sentry-python +* Author(s): Sentry Team and Contributors + +### License Text + +``` +MIT License + +Copyright (c) 2018 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + ## setuptools (82.0.1) - UNKNOWN Most extensible Python build backend with support for C/C++ extension modules diff --git a/pyproject.toml b/pyproject.toml index b2ebf65..3dea53e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dependencies = [ "pydantic>=2,<3", "pydantic-settings>=2,<3", "rich>=14,<15", + "sentry-sdk>=2,<3", ] [dependency-groups] diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 2519312..d0a660f 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -12,6 +12,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei | **process** | Current process introspection | `ProcessInfo`, `ParentProcessInfo` Pydantic models and `get_process_info()` for runtime process metadata; `SUBPROCESS_CREATION_FLAGS` for subprocess creation | | **api.exceptions** | API exception hierarchy and FastAPI handlers | `ApiException` (500), `NotFoundException` (404), `AccessDeniedException` (401); `api_exception_handler`, `unhandled_exception_handler`, `validation_exception_handler` for FastAPI registration | | **log** | Configurable loguru logging initialisation | `logging_initialize(project_name, version, env_file, filter_func)`, `LogSettings` (env-prefix configurable), `InterceptHandler` for stdlib-to-loguru bridging | +| **sentry** | Configurable Sentry integration | `sentry_initialize(project_name, version, environment, integrations, …)`, `SentrySettings` (env-prefix configurable), `set_sentry_user(user, role_claim)` for Auth0 user context | | **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory | | **di** | Dependency injection | `locate_subclasses`, `locate_implementations`, `load_modules`, `discover_plugin_packages`, `clear_caches`, `PLUGIN_ENTRY_POINT_GROUP` for plugin and subclass discovery | | **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status | @@ -50,6 +51,19 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Dependencies**: `loguru>=0.7,<1`, `platformdirs>=4,<5` (mandatory) - **Import**: `from aignostics_foundry_core.log import logging_initialize, LogSettings, InterceptHandler` +### sentry + +**Configurable Sentry integration for error tracking and performance monitoring** + +- **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)` β€” reads from `FOUNDRY_SENTRY_*` env vars by default; override prefix and env file via constructor kwargs (e.g. `SentrySettings(_env_prefix="BRIDGE_SENTRY_", _env_file=".env")`). 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(project_name, version, environment, integrations, repository_url, documentation_url, is_container, is_test, is_cli, is_library, env_prefix, 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` +- **Dependencies**: `sentry-sdk>=2,<3` (mandatory); `loguru>=0.7,<1` +- **Import**: `from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user` + ### models **Shared output format enum for CLI and API responses** diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py new file mode 100644 index 0000000..654d7f9 --- /dev/null +++ b/src/aignostics_foundry_core/sentry.py @@ -0,0 +1,360 @@ +"""Sentry integration for application monitoring.""" + +import re +import urllib.parse +from importlib.util import find_spec +from typing import TYPE_CHECKING, Annotated, Any, Literal + +from loguru import logger +from pydantic import AfterValidator, BeforeValidator, Field, PlainSerializer, SecretStr +from pydantic_settings import SettingsConfigDict + +from aignostics_foundry_core.settings import OpaqueSettings, strip_to_none_before_validator + +if TYPE_CHECKING: + from sentry_sdk.integrations import Integration + +_ERR_MSG_MISSING_SCHEME = "Sentry DSN is missing URL scheme (protocol)" +_ERR_MSG_MISSING_NETLOC = "Sentry DSN is missing network location (domain)" +_ERR_MSG_NON_HTTPS = "Sentry DSN must use HTTPS protocol for security" +_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$" + + +def _validate_url_scheme(parsed_url: urllib.parse.ParseResult) -> None: + """Validate that the URL has a scheme. + + Args: + parsed_url: The parsed URL to validate + + Raises: + ValueError: If URL is missing scheme + """ + if not parsed_url.scheme: + raise ValueError(_ERR_MSG_MISSING_SCHEME) + + +def _validate_url_netloc(parsed_url: urllib.parse.ParseResult) -> None: + """Validate that the URL has a network location. + + Args: + parsed_url: The parsed URL to validate + + Raises: + ValueError: If URL is missing network location + """ + if not parsed_url.netloc: + raise ValueError(_ERR_MSG_MISSING_NETLOC) + + +def _validate_https_scheme(parsed_url: urllib.parse.ParseResult) -> None: + """Validate that the URL uses HTTPS scheme. + + Args: + parsed_url: The parsed URL to validate + + Raises: + ValueError: If URL doesn't use HTTPS scheme + """ + if parsed_url.scheme != "https": + raise ValueError(_ERR_MSG_NON_HTTPS) + + +def _validate_sentry_domain(netloc_with_auth: str) -> None: + """Validate that the URL uses a valid Sentry domain. + + Args: + netloc_with_auth: The network location with auth part + + Raises: + ValueError: If URL doesn't use a valid Sentry domain + """ + if "@" not in netloc_with_auth: + raise ValueError(_ERR_MSG_INVALID_DOMAIN) + + user_pass, domain = netloc_with_auth.split("@", 1) + full_auth = f"{user_pass}@{domain}" + if not re.match(_VALID_SENTRY_DOMAIN_PATTERN, full_auth): + raise ValueError(_ERR_MSG_INVALID_DOMAIN) + + +def _validate_https_dsn(value: SecretStr | None) -> SecretStr | None: + """Validate that the Sentry DSN is a valid HTTPS URL. + + Args: + value: The DSN value to validate + + Returns: + SecretStr | None: The validated DSN value + + Raises: + ValueError: If DSN isn't a valid HTTPS URL with specific error details + """ + if value is None: + return value + + dsn = value.get_secret_value() + try: + parsed_url = urllib.parse.urlparse(dsn) + + _validate_url_scheme(parsed_url) + _validate_url_netloc(parsed_url) + _validate_https_scheme(parsed_url) + _validate_sentry_domain(parsed_url.netloc) + + except ValueError as exc: + raise exc from None + except Exception as exc: + error_message = _ERR_MSG_INVALID_FORMAT + raise ValueError(error_message) from exc + + return value + + +class SentrySettings(OpaqueSettings): + """Configuration settings for Sentry integration. + + Reads from environment variables with the ``FOUNDRY_SENTRY_`` prefix by + default. Callers can supply a project-specific prefix or env file at + instantiation time using Pydantic Settings v2 constructor kwargs:: + + settings = SentrySettings(_env_prefix="BRIDGE_SENTRY_", _env_file=".env") + """ + + model_config = SettingsConfigDict( + env_prefix="FOUNDRY_SENTRY_", + env_file_encoding="utf-8", + extra="ignore", + ) + + enabled: Annotated[ + bool, + Field( + description="Enable remote error and profile collection via Sentry", + default=False, + ), + ] + + dsn: Annotated[ + SecretStr | None, + BeforeValidator(strip_to_none_before_validator), + AfterValidator(_validate_https_dsn), + PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"), + Field(description="Sentry DSN", examples=["https://SECRET@SECRET.ingest.de.sentry.io/SECRET"], default=None), + ] + + debug: Annotated[ + bool, + Field(description="Debug (https://docs.sentry.io/platforms/python/configuration/options/)", default=False), + ] + + send_default_pii: Annotated[ + bool, + Field( + description="Send default personal identifiable information (https://docs.sentry.io/platforms/python/configuration/options/)", + default=False, + ), + ] + + max_breadcrumbs: Annotated[ + int, + Field( + description="Max breadcrumbs (https://docs.sentry.io/platforms/python/configuration/options/#max_breadcrumbs)", + ge=0, + default=50, + ), + ] + + sample_rate: Annotated[ + float, + Field( + ge=0.0, + description="Sample Rate (https://docs.sentry.io/platforms/python/configuration/sampling/#sampling-error-events)", + default=1.0, + ), + ] + + traces_sample_rate: Annotated[ + float, + Field( + ge=0.0, + description="Traces Sample Rate (https://docs.sentry.io/platforms/python/configuration/sampling/#configuring-the-transaction-sample-rate)", + default=0.1, + ), + ] + + profiles_sample_rate: Annotated[ + float, + Field( + ge=0.0, + description="Profiles Sample Rate (https://docs.sentry.io/platforms/python/tracing/#configure)", + default=0.1, + ), + ] + + profile_session_sample_rate: Annotated[ + float, + Field( + ge=0.0, + description="Profile Session Sample Rate (https://docs.sentry.io/platforms/python/tracing/#configure)", + default=0.1, + ), + ] + + profile_lifecycle: Annotated[ + Literal["manual", "trace"], + Field( + description="Profile Lifecycle (https://docs.sentry.io/platforms/python/tracing/#configure)", + default="trace", + ), + ] + + enable_logs: Annotated[ + bool, + Field( + description="Enable Sentry log integration (https://docs.sentry.io/platforms/python/logging/)", + default=True, + ), + ] + + +def sentry_initialize( # noqa: PLR0913, PLR0917 + project_name: str, + version: str, + environment: str, + integrations: "list[Integration] | None", + repository_url: str = "", + documentation_url: str = "", + is_container: bool = False, + is_test: bool = False, + is_cli: bool = False, + is_library: bool = False, + env_prefix: str = "FOUNDRY_SENTRY_", + env_file: str | None = None, +) -> bool: + """Initialize Sentry integration. + + All project-specific metadata is passed as explicit parameters rather than + read from project-level constants, making this function reusable across + any project. + + Args: + project_name: Project name used in the Sentry release tag and context. + version: Full version string (e.g. ``"1.2.3+abc1234"``). + environment: Deployment environment string (e.g. ``"production"``). + integrations: List of Sentry SDK integrations to register, or ``None``. + repository_url: URL of the source repository (optional context field). + documentation_url: URL of the project documentation (optional context field). + is_container: Whether the application runs inside a container. + is_test: Whether the application is running in test mode. + is_cli: Whether the application is running as a CLI. + is_library: Whether the application is running in library mode. + env_prefix: Environment variable prefix for ``SentrySettings`` + (default ``"FOUNDRY_SENTRY_"``). + env_file: Optional path to an ``.env`` file for settings overrides. + + Returns: + bool: ``True`` if Sentry was initialised successfully, ``False`` otherwise. + """ + settings = SentrySettings(_env_prefix=env_prefix, _env_file=env_file) # pyright: ignore[reportCallIssue] + + if not find_spec("sentry_sdk") or not settings.enabled or settings.dsn is None: + logger.trace("Sentry integration is disabled or sentry_sdk not found, initialization skipped.") + return False + + import sentry_sdk # noqa: PLC0415 + from sentry_sdk.integrations.logging import ignore_logger # noqa: PLC0415 + + sentry_sdk.init( + release=f"{project_name}@{version}", + environment=environment, + dsn=settings.dsn.get_secret_value().strip(), + max_breadcrumbs=settings.max_breadcrumbs, + debug=settings.debug, + send_default_pii=settings.send_default_pii, + 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, + integrations=integrations if integrations is not None else [], + ) + sentry_sdk.set_context( + "aignx/base", + { + "project_name": project_name, + "repository_url": repository_url, + "documentation_url": documentation_url, + "version_full": version, + "in_container": is_container, + "test_mode": is_test, + "cli_mode": is_cli, + "library_mode": is_library, + }, + ) + + ignore_logger("azure.storage.blob._shared.avro.schema") + ignore_logger("PIL.PngImagePlugin") + ignore_logger("matplotlib") + ignore_logger("faker.factory") + logger.trace("Sentry integration initialized.") + + return True + + +def set_sentry_user(user: dict[str, Any] | None, role_claim: str | None = None) -> None: + """Set user context for Sentry error tracking. + + Safely sets user information in Sentry scope. Does nothing if: + - sentry_sdk is not installed + - user is None (clears user context) + + This function should be called after successful authentication + to enrich error reports with user context. + + Args: + user: User dict from Auth0 containing fields like 'sub' (user ID), + 'email', 'name', 'org_id', 'org_name', 'role', etc. + 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(None) # Clear user context + """ + if not find_spec("sentry_sdk"): + return + + import sentry_sdk # noqa: PLC0415 + + if user is None: + sentry_sdk.set_user(None) + return + + # Direct mappings from Auth0 user claims to Sentry user context + 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: + field_mappings.append((role_claim, "role")) + + sentry_user: dict[str, str] = {} + for source_key, target_key in field_mappings: + if value := user.get(source_key): + sentry_user[target_key] = value + + if sentry_user: + sentry_sdk.set_user(sentry_user) diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py new file mode 100644 index 0000000..e73aa7c --- /dev/null +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -0,0 +1,100 @@ +"""Tests for aignostics_foundry_core.sentry.""" + +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user + +_VALID_DSN = "https://abc123def456@o99999.ingest.de.sentry.io/1234567" +_PROJECT = "testproject" +_VERSION = "1.0.0" +_ENVIRONMENT = "test" + + +@pytest.mark.unit +class TestSentryInitialize: + """Behavioural tests for sentry_initialize().""" + + def test_sentry_initialize_returns_false_when_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Returns False when FOUNDRY_SENTRY_ENABLED is not set (default False).""" + monkeypatch.delenv("FOUNDRY_SENTRY_ENABLED", raising=False) + result = sentry_initialize( + project_name=_PROJECT, + version=_VERSION, + environment=_ENVIRONMENT, + integrations=None, + ) + assert result is False + + def test_sentry_initialize_returns_false_when_sdk_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Returns False when sentry_sdk is not importable (find_spec returns None).""" + monkeypatch.setenv("FOUNDRY_SENTRY_ENABLED", "true") + monkeypatch.setenv("FOUNDRY_SENTRY_DSN", _VALID_DSN) + with patch("aignostics_foundry_core.sentry.find_spec", return_value=None): + result = sentry_initialize( + project_name=_PROJECT, + version=_VERSION, + environment=_ENVIRONMENT, + integrations=None, + ) + assert result is False + + +@pytest.mark.unit +class TestSentrySettings: + """Behavioural tests for SentrySettings validation.""" + + def test_sentry_settings_rejects_invalid_dsn_http_scheme(self) -> None: + """DSN with http:// scheme raises ValidationError.""" + with pytest.raises(ValidationError): + SentrySettings(dsn="http://abc123@o99999.ingest.de.sentry.io/123") # pyright: ignore[reportCallIssue] + + def test_sentry_settings_rejects_invalid_dsn_missing_domain(self) -> None: + """DSN with non-Sentry domain raises ValidationError.""" + with pytest.raises(ValidationError): + SentrySettings(dsn="https://abc123@example.com/123") # pyright: ignore[reportCallIssue] + + def test_sentry_settings_accepts_valid_dsn(self) -> None: + """Well-formed DSN with ingest.de.sentry.io domain is accepted.""" + settings = SentrySettings(dsn=_VALID_DSN) # pyright: ignore[reportCallIssue] + assert settings.dsn is not None + assert settings.dsn.get_secret_value() == _VALID_DSN + + def test_sentry_settings_accepts_valid_dsn_us_region(self) -> None: + """Well-formed DSN with ingest.us.sentry.io domain is accepted.""" + dsn = "https://abc123def456@o99999.ingest.us.sentry.io/1234567" + settings = SentrySettings(dsn=dsn) # pyright: ignore[reportCallIssue] + assert settings.dsn is not None + assert settings.dsn.get_secret_value() == dsn + + def test_sentry_settings_default_disabled(self) -> None: + """Sentry is disabled by default (no env vars set).""" + settings = SentrySettings() # pyright: ignore[reportCallIssue] + assert settings.enabled is False + + +@pytest.mark.unit +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_sdk.set_user", mock_set_user): + set_sentry_user({"sub": "auth0|x"}) + mock_set_user.assert_called_once_with({"id": "auth0|x"}) + + 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_sdk.set_user", mock_set_user): + set_sentry_user(None) + mock_set_user.assert_called_once_with(None) + + 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|x"}) diff --git a/uv.lock b/uv.lock index f45d537..64ef86e 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "rich" }, + { name = "sentry-sdk" }, ] [package.dev-dependencies] @@ -57,6 +58,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2,<3" }, { name = "pydantic-settings", specifier = ">=2,<3" }, { name = "rich", specifier = ">=14,<15" }, + { name = "sentry-sdk", specifier = ">=2,<3" }, ] [package.metadata.requires-dev] @@ -1889,6 +1891,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] +[[package]] +name = "sentry-sdk" +version = "2.56.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" }, +] + [[package]] name = "setuptools" version = "82.0.1" From 1d65423e2e641fd234ed32c827ac27e382a079cb Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Thu, 26 Mar 2026 16:24:00 +0100 Subject: [PATCH 02/11] feat(user_agent): add parameterised user_agent() --- src/aignostics_foundry_core/AGENTS.md | 15 +++++ src/aignostics_foundry_core/user_agent.py | 39 ++++++++++++ .../user_agent_test.py | 61 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 src/aignostics_foundry_core/user_agent.py create mode 100644 tests/aignostics_foundry_core/user_agent_test.py diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index d0a660f..5e6a429 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -13,6 +13,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei | **api.exceptions** | API exception hierarchy and FastAPI handlers | `ApiException` (500), `NotFoundException` (404), `AccessDeniedException` (401); `api_exception_handler`, `unhandled_exception_handler`, `validation_exception_handler` for FastAPI registration | | **log** | Configurable loguru logging initialisation | `logging_initialize(project_name, version, env_file, filter_func)`, `LogSettings` (env-prefix configurable), `InterceptHandler` for stdlib-to-loguru bridging | | **sentry** | Configurable Sentry integration | `sentry_initialize(project_name, version, environment, integrations, …)`, `SentrySettings` (env-prefix configurable), `set_sentry_user(user, role_claim)` for Auth0 user context | +| **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 | | **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory | | **di** | Dependency injection | `locate_subclasses`, `locate_implementations`, `load_modules`, `discover_plugin_packages`, `clear_caches`, `PLUGIN_ENTRY_POINT_GROUP` for plugin and subclass discovery | | **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status | @@ -145,6 +146,20 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Location**: `aignostics_foundry_core/health.py` - **Dependencies**: `pydantic>=2` +### user_agent + +**Parameterised HTTP user-agent string builder** + +- **Purpose**: Generates a standard HTTP User-Agent header value for outgoing requests, embedding project identity, runtime platform info, and CI/test context +- **Key Features**: + - `user_agent(project_name, version, repository_url)` β€” returns a string in the format `{project_name}-python-sdk/{version} ({platform}; +{repository_url}[; {PYTEST_CURRENT_TEST}][; +{github_run_url}])` + - Automatically includes `PYTEST_CURRENT_TEST` env var when running under pytest + - Automatically includes a `github.com/…/actions/runs/…` URL when `GITHUB_RUN_ID` and `GITHUB_REPOSITORY` env vars are set + - No external dependencies (stdlib `os` and `platform` only) +- **Location**: `aignostics_foundry_core/user_agent.py` +- **Dependencies**: Python stdlib only +- **Import**: `from aignostics_foundry_core.user_agent import user_agent` + ## Architecture