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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,41 @@ set.
| `{PREFIX}SENTRY_PROFILE_LIFECYCLE` | `"trace"` | Profile lifecycle mode: `"trace"` or `"manual"`. |
| `{PREFIX}SENTRY_ENABLE_LOGS` | `true` | Forward log records to Sentry. |

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

Settings class: `OTelSettings`. OpenTelemetry is only initialised when `ENABLED=true` **and** the
standard `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Each signal is then independently toggleable —
traces and metrics default on, logs off (their volume/cost profile differs). Telemetry is exported
via OTLP/gRPC, e.g. to the internal OTel gateway backing the
[Grafana](https://grafana.aignostics.ai/) stack (Tempo/Loki/Prometheus).

| Variable | Default | Description |
|---|---|---|
| `{PREFIX}OTEL_ENABLED` | `false` | Master switch for OpenTelemetry export via OTLP. |
| `{PREFIX}OTEL_TRACES_ENABLED` | `true` | Export traces (once `ENABLED`). |
| `{PREFIX}OTEL_METRICS_ENABLED` | `true` | Export metrics (once `ENABLED`). |
| `{PREFIX}OTEL_LOGS_ENABLED` | `false` | Bridge loguru records into OTLP log export (once `ENABLED`). |

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:

| Variable | Default | Description |
|---|---|---|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | OTLP/gRPC collector endpoint. **Required** — export is skipped if unset. |
| `OTEL_SERVICE_NAME` | project name | Service name attached to all telemetry. Defaults to the `FoundryContext` name. |
| `OTEL_EXPORTER_OTLP_CERTIFICATE` | OS CA bundle, else certifi's | CA file for the exporter's TLS. The Foundry Cloud Run Dockerfile installs the fleet's internal CA into the OS trust store, so this defaults to that bundle (falls back to certifi's public-roots-only bundle if it isn't present, e.g. running locally); set explicitly to override. |
| `OTEL_RESOURCE_ATTRIBUTES` | unset | Extra resource attributes, comma-separated `key=value` pairs. |
| `OTEL_SEMCONV_STABILITY_OPT_IN` | `http` | Opts HTTP instrumentation into the stable semantic conventions (low-cardinality route-template span names) instead of the old, experimental ones. |

Process-level tracing/metrics/logs are set up by `boot()`. `boot()` also applies default
auto-instrumentors (HTTPX, SQLAlchemy) when traces are enabled — override via
`boot(otel_instrumentors=[...])`, or pass `[]` to opt out. Request-level FastAPI spans are
instrumented automatically too: `init_api()` applies `instrument_fastapi()` to the app it builds
(and to every versioned sub-app) — no explicit call needed. A project that constructs its
`FastAPI` instance some other way can call `instrument_fastapi(app)` directly, once, after
construction.

#### Database (`{PREFIX}DB_`)

Settings class: `DatabaseSettings`. Database configuration is only activated when `{PREFIX}DB_URL`
Expand Down
252 changes: 252 additions & 0 deletions docs/decisions/0006-cloud-run-otel-telemetry-export.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ dependencies = [
"fastapi>=0.110,<1",
"httpx2>=2.2.0,<3",
"loguru>=0.7,<1",
"opentelemetry-sdk>=1.27,<2",
"opentelemetry-exporter-otlp-proto-grpc>=1.27,<2",
"opentelemetry-instrumentation-fastapi>=0.48b0,<1",
"opentelemetry-instrumentation-httpx>=0.48b0,<1",
"opentelemetry-instrumentation-sqlalchemy>=0.48b0,<1",
"opentelemetry-resourcedetector-gcp>=1.12.0a0,<2",
"PyJWT>=2.10,<3",
"platformdirs>=4,<5",
"psutil>=6",
Expand Down
10 changes: 10 additions & 0 deletions src/aignostics_foundry_core/api/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,13 +449,21 @@ def init_api(
app.
**fastapi_kwargs: Extra keyword arguments forwarded to ``FastAPI()``.

Instruments the returned app (and every versioned sub-app, since Starlette
dispatches a mounted sub-app's requests through its own middleware stack,
not the root's) for request-level OTel tracing via
:func:`~aignostics_foundry_core.otel.instrument_fastapi`. No-op if
OpenTelemetry FastAPI instrumentation isn't installed.

Returns:
A configured ``FastAPI`` instance.
"""
from fastapi import FastAPI # noqa: PLC0415
from fastapi.exceptions import RequestValidationError # noqa: PLC0415
from pydantic import ValidationError # noqa: PLC0415

from aignostics_foundry_core.otel import instrument_fastapi # noqa: PLC0415

api = FastAPI(root_path=root_path, lifespan=lifespan, **fastapi_kwargs)

for exc_class, handler in exception_handler_registrations or []:
Expand Down Expand Up @@ -484,6 +492,8 @@ def init_api(
exc_class_or_status_code=ValidationError, handler=validation_exception_handler
)
version_app.add_exception_handler(exc_class_or_status_code=Exception, handler=unhandled_exception_handler)
instrument_fastapi(version_app)
api.mount(f"/{version_name}", version_app)

instrument_fastapi(api)
return api
33 changes: 28 additions & 5 deletions src/aignostics_foundry_core/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from aignostics_foundry_core.foundry import get_context
from aignostics_foundry_core.log import logging_initialize
from aignostics_foundry_core.otel import otel_initialize
from aignostics_foundry_core.process import get_process_info
from aignostics_foundry_core.sentry import sentry_initialize

Expand All @@ -37,6 +38,7 @@
from collections.abc import Callable

from loguru import Record
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from sentry_sdk.integrations import Integration

from aignostics_foundry_core.foundry import FoundryContext
Expand All @@ -47,6 +49,7 @@
def boot(
context: FoundryContext | None = None,
sentry_integrations: list[Integration] | None = None,
otel_instrumentors: list[BaseInstrumentor] | None = None,
log_filter: Callable[[Record], bool] | None = None,
show_cmdline: bool = True,
) -> None:
Expand All @@ -61,8 +64,10 @@ def boot(
2. Initialise loguru logging via :func:`~aignostics_foundry_core.log.logging_initialize`.
3. Amend the SSL trust chain with *truststore* and *certifi*.
4. Initialise Sentry via :func:`~aignostics_foundry_core.sentry.sentry_initialize`.
5. Log a boot message with version, PID, and process information.
6. Register an atexit shutdown message.
5. Initialise OpenTelemetry via :func:`~aignostics_foundry_core.otel.otel_initialize`.
6. Log a boot message with version, PID, process information, and whether
Sentry/OpenTelemetry actually initialised.
7. Register an atexit shutdown message.

Args:
context: :class:`~aignostics_foundry_core.foundry.FoundryContext` providing
Expand All @@ -72,6 +77,11 @@ def boot(
is used.
sentry_integrations: List of Sentry SDK integrations to register, or
``None`` to skip Sentry initialisation.
otel_instrumentors: List of OTel ``BaseInstrumentor`` instances to apply
(e.g. ``SQLAlchemyInstrumentor``), forwarded to
:func:`~aignostics_foundry_core.otel.otel_initialize`. ``None`` (the
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`.
show_cmdline: Whether to include the process command line in the
Expand All @@ -86,11 +96,17 @@ def boot(
_parse_env_args(ctx.name)
logging_initialize(filter_func=log_filter, context=ctx)
_amend_ssl_trust_chain()
sentry_initialize(
sentry_initialized = sentry_initialize(
integrations=sentry_integrations,
context=ctx,
)
_log_boot_message(context=ctx, show_cmdline=show_cmdline)
otel_initialized = otel_initialize(context=ctx, instrumentors=otel_instrumentors)
_log_boot_message(
context=ctx,
show_cmdline=show_cmdline,
sentry_initialized=sentry_initialized,
otel_initialized=otel_initialized,
)
_register_shutdown_message(project_name=ctx.name, version=ctx.version)
logger.trace("Boot sequence completed successfully.")

Expand Down Expand Up @@ -158,20 +174,27 @@ def _amend_ssl_trust_chain() -> None:
def _log_boot_message(
context: FoundryContext,
show_cmdline: bool = True,
sentry_initialized: bool = False,
otel_initialized: bool = False,
) -> None:
"""Log a boot message including version, PID, and parent process info.

Args:
context: Project context supplying name, version, library mode flag, and
project root path.
show_cmdline: Whether to append the process command line.
sentry_initialized: Whether :func:`~aignostics_foundry_core.sentry.sentry_initialize`
actually initialised Sentry (``False`` if disabled or misconfigured).
otel_initialized: Whether :func:`~aignostics_foundry_core.otel.otel_initialize`
actually initialised OpenTelemetry (``False`` if disabled or misconfigured).
"""
process_info = get_process_info(context=context)
mode_suffix = ", library-mode" if context.is_library else ""
message = (
f"⭐ Booting {context.name} v{context.version} "
f"(project root {process_info.project_root}, pid {process_info.pid}), "
f"parent '{process_info.parent.name}' (pid {process_info.parent.pid}){mode_suffix}"
f"parent '{process_info.parent.name}' (pid {process_info.parent.pid}){mode_suffix}, "
f"sentry: {'on' if sentry_initialized else 'off'}, otel: {'on' if otel_initialized else 'off'}"
)
if show_cmdline and process_info.cmdline:
cmdline_str = " ".join(process_info.cmdline)
Expand Down
Loading
Loading