Skip to content

feat(OP-3108): Initialize OpenTelemetry in foundry-python-core#96

Merged
aig-hannes merged 3 commits into
mainfrom
feat/otel-auto-instrumentation
Jul 21, 2026
Merged

feat(OP-3108): Initialize OpenTelemetry in foundry-python-core#96
aig-hannes merged 3 commits into
mainfrom
feat/otel-auto-instrumentation

Conversation

@fubezz

@fubezz fubezz commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What this does

Gives every Foundry service OpenTelemetry support (traces, metrics, and optionally logs) via a single core version bump — no per-service instrumentation code required. Once a service turns it on, its telemetry lands in the internal Tempo/Loki/Prometheus stack over OTLP/gRPC, via ADR 0006.

Part of OP-3108. This is the client/SDK side; the gateway it talks to is gitops#3459, and the one-line template wiring (now largely unnecessary — see below) is foundry-python#385.

How it's shaped

Follows the same pattern already used for Sentry: a pydantic-settings-driven enabled flag, a single otel_initialize() entry point called from boot(), and graceful no-ops when the SDK isn't installed or isn't configured.

  • Traces, metrics, logs are independently toggleable. Traces and metrics default on once the master switch is on; logs default off — different volume/cost profile, so it shouldn't be forced on every service that just wants tracing.
  • Endpoint, service name, and certificate come from the standard, unprefixed OTel env vars (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_CERTIFICATE) rather than reinventing project-prefixed ones, so services already familiar with the OTel spec don't need to learn a second set of names.
  • Logs: this project uses loguru, not stdlib logging, and loguru has no first-party OTel integration. Added a sink that converts each loguru record into a stdlib LogRecord and forwards it to OTel's own LoggingHandler, reusing the SDK's conversion/trace-correlation logic instead of reimplementing OTLP log export.
  • Default instrumentors, with full override: HTTPXClientInstrumentor (so an outbound httpx call to another Foundry service continues the caller's trace instead of starting a new, disconnected one) and SQLAlchemyInstrumentor (every Foundry service gets a SQLAlchemy-backed database layer, so DB spans are as much a baseline expectation as HTTP ones). Both are optional-dependency-gated and only applied once a real TracerProvider is actually registered — a project can pass its own list, or [] to opt out entirely.
  • FastAPI instrumentation now happens automatically tooinit_api() (the function that actually constructs the app instance, and always runs after boot()) instruments the returned app and every versioned sub-app before returning, and skips it entirely when no real TracerProvider is registered (mirroring the guard the other instrumentors use, so a service with OTel disabled doesn't pay for OpenTelemetryMiddleware on every request for nothing). This closes the original "known follow-up" below — outside of the foundry-python template, nothing extra needs to be called.
  • TLS: OTEL_EXPORTER_OTLP_CERTIFICATE defaults to a combined CA bundle (certifi's system roots + the fleet's internal aignx-ca-root-authority), since the gateway's TLS-facing endpoint is signed by that internal CA — bundling both keeps a service that points at a public vendor instead working too.
  • boot() logs whether it actually initializedotel: on/off alongside the existing sentry: on/off, so you can tell from the boot log whether telemetry is actually flowing without checking env vars by hand.
  • Guards throughout against boot() running twice in the same process (tests, a subprocess that inherited parent state) — each provider no-ops if a real one is already registered, instead of stacking a second exporter on top.

Verified end-to-end

Beyond unit/integration tests, this was smoke-tested against the real sandbox OTLP gateway from a Docker container: TLS handshake succeeds via the combined CA bundle (where a raw curl with only system roots fails), and both SpanExportResult.SUCCESS and LogRecordExportResult.SUCCESS come back from the gateway.

Test plan

  • mise run lint — ruff, pyrefly, deptry all pass.
  • mise run test_unit — 346 unit tests passing, otel.py at 100% coverage.
  • mise run test_integration
  • mise run pre_commit_run_all
  • Manual smoke test against the real sandbox OTLP gateway (TLS + span/log export, see above).

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

Files with missing lines Coverage Δ
src/aignostics_foundry_core/api/core.py 93.33% <100.00%> (+0.17%) ⬆️
src/aignostics_foundry_core/boot.py 79.01% <100.00%> (+0.53%) ⬆️
src/aignostics_foundry_core/otel.py 100.00% <100.00%> (ø)

@fubezz
fubezz force-pushed the feat/otel-auto-instrumentation branch from a424026 to 3a3efe0 Compare July 9, 2026 14:16
Comment thread src/aignostics_foundry_core/boot.py Outdated
Comment thread src/aignostics_foundry_core/otel.py
Comment thread src/aignostics_foundry_core/otel.py
@fubezz

fubezz commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Notes from testing this against a locally-generated Foundry service (OP-3108, sandbox). A few design points worth considering before merge, none blocking:

1. otel_initialize() has no equivalent to SENTRY_INTEGRATIONS

sentry_initialize() takes an explicit integrations: list[Integration] | None built by the generated project's own constants.py (try/except-imported per optional dep, e.g. FastApiIntegration, TyperIntegration). otel_initialize() has no equivalent — it only sets up the bare TracerProvider/MeterProvider/LoggerProvider. Everything downstream of the inbound HTTP request (DB queries, outbound httpx calls, Redis, etc.) is invisible unless something calls the relevant opentelemetry-instrumentation-* package's .instrument().

Most of those packages subclass a common BaseInstrumentor with a no-argument .instrument() — genuinely Sentry-integration-shaped, so the same pattern would fit:

# otel.py
def otel_initialize(*, context=None, instrumentors: list[BaseInstrumentor] | None = None) -> bool:
    ...
    for instrumentor in instrumentors or []:
        instrumentor.instrument()

with a project's constants.py building OTEL_INSTRUMENTORS = [HTTPXClientInstrumentor(), SQLAlchemyInstrumentor()] the same way it builds SENTRY_INTEGRATIONS today.

Concretely, HTTPXClientInstrumentor is the one I'd prioritize: without it, an outbound httpx call to another Foundry service doesn't get the W3C traceparent header injected, so the callee's span starts a new disconnected trace instead of a child — which undercuts the actual point of this ADR (getting Cloud Run into the same distributed-tracing picture as everything else).

FastAPIInstrumentor stays exactly as it is (instrument_fastapi(app), called separately from Service.api()) — it's the odd one out because ASGI middleware has to wrap the live app instance, which doesn't exist yet at boot() time. That's a real API shape difference, not an oversight, so full parity with the Sentry list isn't achievable — only partial.

Separately, worth a doc note (in otel_initialize()'s docstring or the ADR's consequences section): manual/business-level spans and custom metrics already work today, no signature change needed — trace.get_tracer(__name__) / metrics.get_meter(__name__) just work anywhere once otel_initialize() has set the global providers.

2. boot() silently discards the init result

otel_initialize() and sentry_initialize() both return bool (whether they actually initialized vs. no-op'd because the master switch was off or the endpoint/DSN was missing), but boot() doesn't do anything with either return value. Today there's no log line telling you at runtime whether OTel/Sentry actually came up. Appending something like , otel: {on/off}, sentry: {on/off} to the existing _log_boot_message() call would close that gap cheaply, using data that's already computed.

3. Considered and rejected: master enabled switch as a boot() parameter

To make it "visible in code" rather than purely env-driven. Checked SentrySettings.enabled — it's the exact same env-only pattern as OTelSettings.enabled (no boot() param for it either), so promoting only OTel's switch to a boot() arg would break symmetry with Sentry rather than restore it, and risks a second source of truth (hardcode True in generated code but still gate on env, or duplicate the env read outside OTelSettings). Point 2 above (log the actual init result) gets the same visibility without the architecture change.

@fubezz fubezz changed the title feat(otel): initialize OpenTelemetry tracing and metrics in boot() feat(OP-3108): Initialize OpenTelemetry in foundry-python-core Jul 20, 2026
@fubezz
fubezz marked this pull request as ready for review July 20, 2026 09:31
@fubezz
fubezz requested a review from a team as a code owner July 20, 2026 09:31
@fubezz
fubezz force-pushed the feat/otel-auto-instrumentation branch 2 times, most recently from 8abdc9c to d5c2a40 Compare July 20, 2026 11:25
Adds process-wide OTel instrumentation via boot(), following the existing
Sentry pattern: independently-toggleable traces/metrics/logs, default
HTTPX/SQLAlchemy/FastAPI instrumentors (with full override), GCP resource
detection, and a combined CA bundle for the internal OTel gateway's TLS.

Request-level FastAPI tracing is applied automatically by init_api() (to the
root app and every versioned sub-app), gated on a real TracerProvider
actually being registered — no per-service wiring needed.

See docs/decisions/0006-cloud-run-otel-telemetry-export.md for the design
rationale and consequences.

Ref: OP-3108

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@fubezz
fubezz force-pushed the feat/otel-auto-instrumentation branch from d5c2a40 to 3dab10d Compare July 20, 2026 11:27

@aig-hannes aig-hannes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good to me

fubezz and others added 2 commits July 20, 2026 14:03
Two changes requested in review:

- Default OTEL_SEMCONV_STABILITY_OPT_IN=http, opting HTTPXClientInstrumentor
  and FastAPIInstrumentor into the stable HTTP semantic conventions instead
  of the old, experimental ones. The old conventions name a span after the
  literal request path (unbounded cardinality for any route with a path
  parameter); the stable ones require a low-cardinality route template.

- Move internal-CA trust from a Python-side combined bundle (certifi +
  bundled cert, merged into a temp file at runtime) to the OS trust store,
  installed by the generated Dockerfile via `update-ca-certificates`
  (foundry-python#385). OTEL_EXPORTER_OTLP_CERTIFICATE now just points at
  the resulting OS bundle if present, falling back to certifi's alone
  otherwise — trust becomes independent of any one language/library, and
  this repo no longer needs to bundle/ship the CA cert itself, write a temp
  file, or register atexit cleanup for it. Matches the same pattern already
  used in pathosearch-portal-onboarder and pviz.

Ref: OP-3108

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fault tests

_default_otlp_certificate_setdefault() checks pathlib.Path(...).is_file(),
which doesn't call os.path.isfile() internally, so patching os.path.isfile
was a no-op. The tests only passed locally because /etc/ssl/certs/ca-certificates.crt
happens to be absent on macOS dev machines; on the Linux CI runner it genuinely
exists, so the real (unmocked) is_file() returned True regardless of the intended
mock, breaking the two tests that expected the certifi fallback branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@aig-hannes
aig-hannes merged commit 2f92818 into main Jul 21, 2026
12 checks passed
@aig-hannes
aig-hannes deleted the feat/otel-auto-instrumentation branch July 21, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants