Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ boot()
populate every field:

- `name`, `version`, `version_full` — from `importlib.metadata`
- `commit_sha` — from `COMMIT_SHA`; `None` when unset or blank
- `environment` — resolved from env vars in priority order (see [Configuration reference](#configuration-reference) below)
- `env_prefix` (`"MYPROJECT_"`) — used by every settings class; all env vars for this project share this prefix
- `is_container`, `is_cli`, `is_test`, `is_library` — detected automatically
Expand Down Expand Up @@ -117,8 +118,8 @@ Read directly by `FoundryContext.from_package()` — no settings class.

#### Build metadata

Read by `FoundryContext.from_package()` to build `version_full` and `version_with_vcs_ref`. All
optional; most useful in CI.
Read by `FoundryContext.from_package()` to build `version_full` and `version_with_vcs_ref`.
`COMMIT_SHA` also sets `commit_sha`. All optional; most useful in CI.

| Variable | Default | Description |
|---|---|---|
Expand Down Expand Up @@ -175,6 +176,9 @@ 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.

The Sentry release is `<name>@<version>+<COMMIT_SHA>`, or `<name>@<version>` if `COMMIT_SHA` is
not set. To link commits to the release, CI must create the same release name with `sentry-cli`.

#### OpenTelemetry (`{PREFIX}OTEL_`)

Settings class: `OTelSettings`. OpenTelemetry is only initialised when `ENABLED=true` **and** the
Expand Down
7 changes: 4 additions & 3 deletions src/aignostics_foundry_core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
| **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 |
| **foundry** | Project context injection | `PackageMetadata`, `FoundryContext`, `FoundryContext.from_package()`, `set_context()`, `get_context()` — centralised project-specific values (name, version, `version_full`, `version_with_vcs_ref`, environment, env files, URLs, `python_version`, `metadata: PackageMetadata` (description, author_name, author_email), runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`, `database: DatabaseSettings \| None`) derived from package metadata and environment variables; `from_package()` populates `metadata` from `importlib.metadata` and `database` from `{env_prefix}DB_*` env vars when `{env_prefix}DB_URL` is present |
| **foundry** | Project context injection | `PackageMetadata`, `FoundryContext`, `FoundryContext.from_package()`, `set_context()`, `get_context()` — centralised project-specific values (name, version, `version_full`, `version_with_vcs_ref`, `commit_sha`, environment, env files, URLs, `python_version`, `metadata: PackageMetadata` (description, author_name, author_email), runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`, `database: DatabaseSettings \| None`) derived from package metadata and environment variables; `from_package()` populates `metadata` from `importlib.metadata` and `database` from `{env_prefix}DB_*` env vars when `{env_prefix}DB_URL` is present |
| **di** | Dependency injection | `locate_subclasses(cls, *, context=None)`, `locate_implementations(cls, *, context=None)`, `load_modules(*, context=None)`, `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 |
| **scheduler** | Chancy-backed job module base class | `BaseJoblet` ABC with `get_queues()`, `get_jobs()`, `get_crons()`, `get_triggers()` hooks and `register_queues/jobs/crons/triggers()` / `unregister_queues/jobs/crons/triggers()` lifecycle methods; `key()` returns second-to-last module component for identification |
Expand All @@ -49,7 +49,8 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
Constructor: `PackageMetadata.from_name(package_name)` — reads all five fields from
`importlib.metadata` (``Summary``, ``Author-email``, ``Project-URL``).
Defaults to empty values for direct construction (e.g. in tests).
- `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `version_with_vcs_ref`, `environment`,
- `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `version_with_vcs_ref`,
`commit_sha: str | None` (from `COMMIT_SHA`, stripped; `None` when unset or blank), `environment`,
`env_file: list[Path]`, `env_prefix`, `python_version` (Python runtime version string,
e.g. `"3.11.9"`), `python_version_minor` (computed; major and minor only, e.g. `"3.11"`), `metadata: PackageMetadata` (all package-derived fields: description,
author, URLs; populated by `from_package()` via `PackageMetadata.from_name()`; defaults to
Expand Down Expand Up @@ -183,7 +184,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
- **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants.
- **Key Features**:
- `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `trace_propagation_targets` (`list[str]` of URL regexes, default `[]`; no outbound request gets `sentry-trace` or `baggage` headers; the env var takes a JSON list), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`. 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
- `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 the release `<name>@<version>+<commit_sha>` (`<name>@<version>` without a commit SHA; drops the SHA suffix if the name has `/` or `\` or exceeds 200 characters); 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.68.0,<3` (mandatory); `loguru>=0.7,<1`
Expand Down
20 changes: 14 additions & 6 deletions src/aignostics_foundry_core/foundry.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ class FoundryContext(BaseModel):
(``CI_RUN_ID``, ``CI_RUN_NUMBER``, ``BUILDER``, ``BUILD_DATE``).
Falls back to :attr:`version` when neither variable is set.
"""
commit_sha: str | None = None
"""Commit SHA from the ``COMMIT_SHA`` environment variable.

Whitespace is stripped. ``None`` when the variable is unset, empty or blank.
"""
environment: str
env_file: list[Path] = Field(default_factory=_empty_path_list)
env_prefix: str = ""
Expand Down Expand Up @@ -172,6 +177,7 @@ def from_package(cls, package_name: str) -> FoundryContext:

* ``VCS_REF``, ``COMMIT_SHA``, ``BUILDER``, ``BUILD_DATE``,
``CI_RUN_ID``, ``CI_RUN_NUMBER`` — build metadata for :attr:`version_full`.
``COMMIT_SHA`` also sets :attr:`commit_sha`.
* ``{NAME}_ENVIRONMENT``, ``ENV``, ``VERCEL_ENV``, ``RAILWAY_ENVIRONMENT``
— deployment environment.
* ``{NAME}_ENV_FILE`` — optional extra env-file path inserted at index 2 of
Expand All @@ -191,6 +197,7 @@ def from_package(cls, package_name: str) -> FoundryContext:
environment = _detect_environment(name_upper)
project_path = _find_project_path(package_name)
vcs_ref = os.environ.get("VCS_REF") or (project_path and _get_vcs_ref_from_git(project_path)) or "unknown"
commit_sha = os.getenv("COMMIT_SHA", "unknown")
env_prefix = f"{name_upper}_"
env_files = _build_env_file_list(name, name_upper, environment)
db_url_key = f"{env_prefix}DB_URL"
Expand All @@ -202,8 +209,9 @@ def from_package(cls, package_name: str) -> FoundryContext:
return cls(
name=name,
version=version,
version_full=_build_version_full(version, vcs_ref),
version_with_vcs_ref=_build_version_with_vcs_ref(version, vcs_ref),
version_full=_build_version_full(version, vcs_ref, commit_sha),
version_with_vcs_ref=_build_version_with_vcs_ref(version, vcs_ref, commit_sha),
commit_sha=None if commit_sha == "unknown" else (commit_sha.strip() or None),
Comment on lines +212 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One line is enough. The Dockerfile template sets ARG COMMIT_SHA="", never unknown. With this change, drop the commit_sha local above and put both _build_version_* helpers back to their main versions.

Suggested change
version_full=_build_version_full(version, vcs_ref, commit_sha),
version_with_vcs_ref=_build_version_with_vcs_ref(version, vcs_ref, commit_sha),
commit_sha=None if commit_sha == "unknown" else (commit_sha.strip() or None),
version_full=_build_version_full(version, vcs_ref),
version_with_vcs_ref=_build_version_with_vcs_ref(version, vcs_ref),
commit_sha=os.getenv("COMMIT_SHA", "").strip() or None,

environment=environment,
env_file=env_files,
env_prefix=env_prefix,
Expand Down Expand Up @@ -257,17 +265,17 @@ def _get_vcs_ref_from_git(project_path: Path) -> str:
return "unknown"


def _build_version_full(version: str, vcs_ref: str) -> str:
def _build_version_full(version: str, vcs_ref: str, commit_sha: str) -> str:
"""Append build metadata to *version* from environment variables.

Args:
version: The base version string (e.g. ``"1.2.3"``).
vcs_ref: The VCS ref string (branch name, short SHA, or ``"unknown"``).
commit_sha: The commit SHA, or ``"unknown"``.

Returns:
The version string with optional ``+<metadata>`` suffix.
"""
commit_sha = os.getenv("COMMIT_SHA", "unknown")
builder = os.getenv("BUILDER", "uv")
build_date = os.getenv("BUILD_DATE", "unknown")
ci_run_id = os.getenv("CI_RUN_ID", "unknown")
Expand All @@ -294,18 +302,18 @@ def _build_version_full(version: str, vcs_ref: str) -> str:
return result


def _build_version_with_vcs_ref(version: str, vcs_ref: str) -> str:
def _build_version_with_vcs_ref(version: str, vcs_ref: str, commit_sha: str) -> str:
"""Append VCS ref and commit SHA to *version*, omitting CI build metadata.

Args:
version: The base version string (e.g. ``"1.2.3"``).
vcs_ref: The VCS ref string (branch name, short SHA, or ``"unknown"``).
commit_sha: The commit SHA, or ``"unknown"``.

Returns:
The version string with optional ``+<vcs_ref>[-<commit_sha>]`` suffix,
or the bare *version* when both values are ``"unknown"``.
"""
commit_sha = os.getenv("COMMIT_SHA", "unknown")
parts = [p for p in [vcs_ref, commit_sha] if p != "unknown"]
if not parts:
return version
Expand Down
29 changes: 28 additions & 1 deletion src/aignostics_foundry_core/sentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
_HTTP_BREADCRUMB_CATEGORY = "httplib"
_URL_DATA_KEY = "url"
_URL_PART_DATA_KEYS = frozenset({"http.query", "http.fragment"})
_MAX_RELEASE_LENGTH = 200
_INVALID_RELEASE_CHARS = ("/", "\\")


def _validate_url_scheme(parsed_url: urllib.parse.ParseResult) -> None:
Expand Down Expand Up @@ -177,6 +179,27 @@ def _before_send_transaction(event: "Event", _hint: dict[str, Any]) -> "Event":
return event


def _release_name(ctx: "FoundryContext") -> str:
"""Return the Sentry release ``<name>@<version>[+<commit_sha>]``.

Drops the SHA suffix if Sentry rejects the full name.

Args:
ctx: The context that gives the name, the version and the commit SHA.

Returns:
str: The release name.
"""
base = f"{ctx.name}@{ctx.version}"
if not ctx.commit_sha:
return base
release = f"{base}+{ctx.commit_sha}"
if len(release) > _MAX_RELEASE_LENGTH or any(char in release for char in _INVALID_RELEASE_CHARS):
logger.warning("Sentry release '{}' is invalid, using '{}'.", release, base)
return base
return release


class SentrySettings(OpaqueSettings):
"""Configuration settings for Sentry integration.

Expand Down Expand Up @@ -329,6 +352,10 @@ def sentry_initialize(
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.

The release is ``<name>@<version>+<commit_sha>``, or ``<name>@<version>`` if the context
has no commit SHA. It does not use ``version_full``: that value can contain ``/``, and
Sentry drops such a release. CI can build the same name to link commits.

Args:
integrations: List of Sentry SDK integrations to register, or ``None``.
context: :class:`~aignostics_foundry_core.foundry.FoundryContext` providing
Expand All @@ -353,7 +380,7 @@ def sentry_initialize(
from sentry_sdk.integrations.logging import ignore_logger # ruff: ignore[import-outside-top-level]

sentry_sdk.init(
release=f"{ctx.name}@{ctx.version_full}",
release=_release_name(ctx),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline it, and delete _release_name and its constants. The package name, a PEP 440 version and ${{ github.sha }} can't contain / or \, and together they stay far below 200 characters. The guard only catches a hand-set COMMIT_SHA.

Tests: ..._drops_invalid_commit_sha goes with the guard. ..._valid_with_full_build_metadata repeats ..._has_commit_sha, which already fails if the release goes back to {name}@{version_full}. Dropping it also removes version_full from make_context.

Suggested change
release=_release_name(ctx),
release=f"{ctx.name}@{ctx.version}" + (f"+{ctx.commit_sha}" if ctx.commit_sha else ""),

environment=ctx.environment,
dsn=settings.dsn.get_secret_value().strip(),
max_breadcrumbs=settings.max_breadcrumbs,
Expand Down
30 changes: 30 additions & 0 deletions tests/aignostics_foundry_core/foundry_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
VCS_REF_VALUE = "abc123"
VCS_REF_OVERRIDE = "ci-override-ref"
COMMIT_SHA_VALUE = "deadbeef"
COMMIT_SHA_FULL = "c" * 40
CI_RUN_ID_VALUE = "99"
CI_RUN_NUMBER_VALUE = "42"
BUILD_DATE_VALUE = "2024-01-15"
Expand Down Expand Up @@ -271,6 +272,35 @@ def test_from_package_version_full_omits_builder_and_extra_when_all_unknown(
assert "---" not in ctx.version_full


# ---------------------------------------------------------------------------
# from_package — commit_sha
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_from_package_commit_sha_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""commit_sha equals COMMIT_SHA when it is set."""
monkeypatch.setenv("COMMIT_SHA", COMMIT_SHA_FULL)
ctx = FoundryContext.from_package(PACKAGE_NAME)
assert ctx.commit_sha == COMMIT_SHA_FULL


@pytest.mark.unit
def test_from_package_commit_sha_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
"""commit_sha is None when COMMIT_SHA is unset."""
monkeypatch.delenv("COMMIT_SHA", raising=False)
ctx = FoundryContext.from_package(PACKAGE_NAME)
assert ctx.commit_sha is None


@pytest.mark.unit
def test_from_package_commit_sha_none_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""commit_sha is None when COMMIT_SHA is empty."""
monkeypatch.setenv("COMMIT_SHA", "")
ctx = FoundryContext.from_package(PACKAGE_NAME)
assert ctx.commit_sha is None


# ---------------------------------------------------------------------------
# from_package — project_path
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading