Skip to content

feat(otel): initialize OpenTelemetry tracing and metrics in boot()#96

Draft
fubezz wants to merge 24 commits into
mainfrom
feat/otel-auto-instrumentation
Draft

feat(otel): initialize OpenTelemetry tracing and metrics in boot()#96
fubezz wants to merge 24 commits into
mainfrom
feat/otel-auto-instrumentation

Conversation

@fubezz

@fubezz fubezz commented Jul 7, 2026

Copy link
Copy Markdown

Summary

  • Adds otel_initialize() in a new src/aignostics_foundry_core/otel.py, following the exact same pattern as sentry_initialize(): pydantic-settings-driven enabled flag (env-prefixed per FoundryContext), graceful no-op when the SDK isn't available or isn't enabled.
  • Sets up a process-wide TracerProvider/MeterProvider exporting via OTLP/gRPC, using the standard, unprefixed OpenTelemetry env vars (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, etc.) rather than reinventing project-specific ones — OTEL_SERVICE_NAME defaults to the FoundryContext project name if not explicitly set.
  • Logs: this project uses loguru, not stdlib logging — loguru has no first-party OTel integration. Added a loguru sink (_make_otel_log_sink) that converts each loguru record into a stdlib logging.LogRecord and forwards it to OTel's own LoggingHandler, reusing the SDK's own conversion/trace-correlation logic instead of reimplementing OTLP log export. Gated behind a separate logs_enabled setting, off by default even when enabled is true — log volume/cost characteristics differ from traces/metrics, so it shouldn't be forced on every service that turns on tracing.
  • Wires otel_initialize() into boot(), right after sentry_initialize() — every Foundry service gets this via a single core version bump, no per-service code changes for process-level tracing/metrics(/logs, opt-in).
  • Also exports instrument_fastapi(app) for request-level tracing, since boot() runs before any FastAPI app instance exists.

Related: OP-3108 (expose an OTel endpoint for Foundry Cloud Run services — this is the service-side counterpart, decided against pushing this logic into the foundry-python template since it's not an importable runtime dependency there).

Known follow-up (not done in this PR)

instrument_fastapi(app) needs to actually be called somewhere — but the FastAPI app instance is constructed in Service.api_serve(), which lives in the foundry-python template (not this repo), not in boot(). This PR ships the capability; wiring the one-line call into the template is a separate, small follow-up in foundry-python. Until that lands, request-level FastAPI auto-tracing (spans per HTTP request) isn't automatic — process-level tracing/metrics/logs (manually-created spans, outbound instrumentation, log records) work regardless.

Test plan

  • mise run lint — ruff, pyright, deptry all pass.
  • mise run test_unit — 13 passed, otel.py at 100% coverage.
  • mise run test_integration — 100 passed, otel.py at 100% coverage.
  • mise run pre_commit_run_all — all hooks pass.
  • Unit tests for the loguru→OTel log sink (level mapping including loguru-only TRACE/SUCCESS, message/name/function conversion, exception forwarding) and for the logs_enabled on/off gating.
  • Manual smoke test against a real OTLP endpoint (e.g. once aignx-otel-gateway.aignostics.ai from the gitops side of OP-3108 is live) — not done here, this PR is unit/integration-tested only.

Opened as draft pending review of the design (in particular: the loguru→OTel logs bridge approach and its opt-in gating, and confirmation on the instrument_fastapi follow-up plan).

🤖 Generated with Claude Code

Adds otel_initialize(), following the same enabled-flag + graceful-degradation
pattern as sentry_initialize(): reads the standard OTEL_* env vars, sets up a
process-wide TracerProvider/MeterProvider exporting via OTLP/gRPC, and is a
no-op unless both explicitly enabled and OTEL_EXPORTER_OTLP_ENDPOINT is set.

Called from boot() so every Foundry service gets it via a core version bump,
with no per-service code changes needed for process-level tracing/metrics.

Request-level FastAPI tracing needs one additional call once the app exists
(instrument_fastapi(app), exported here) since boot() runs before the app is
constructed - this still needs wiring into the foundry-python template's
Service.api_serve().

Ref: OP-3108
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
441 1 440 0
View the top 1 failed test(s) by shortest run time
tests.aignostics_foundry_core.otel_test.TestInstrumentFastapi::test_instrument_fastapi_returns_true_and_instruments_app
Stack Traces | 0.119s run time
self = <tests.aignostics_foundry_core.otel_test.TestInstrumentFastapi object at 0x7f782d8964d0>

    def test_instrument_fastapi_returns_true_and_instruments_app(self) -> None:
        """Returns True and calls FastAPIInstrumentor.instrument_app with the given app."""
        mock_app = MagicMock()
>       with patch("opentelemetry.instrumentation.fastapi.FastAPIInstrumentor.instrument_app") as mock_instrument:

tests/aignostics_foundry_core/otel_test.py:268: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../../..../uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/unittest/mock.py:1430: in __enter__
    self.target = self.getter()
                  ^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

name = 'opentelemetry.instrumentation.fastapi.FastAPIInstrumentor'

    def resolve_name(name):
        """
        Resolve a name to an object.
    
        It is expected that `name` will be a string in one of the following
        formats, where W is shorthand for a valid Python identifier and dot stands
        for a literal period in these pseudo-regexes:
    
        W(.W)*
        W(.W)*:(W(.W)*)?
    
        The first form is intended for backward compatibility only. It assumes that
        some part of the dotted name is a package, and the rest is an object
        somewhere within that package, possibly nested inside other objects.
        Because the place where the package stops and the object hierarchy starts
        can't be inferred by inspection, repeated attempts to import must be done
        with this form.
    
        In the second form, the caller makes the division point clear through the
        provision of a single colon: the dotted name to the left of the colon is a
        package to be imported, and the dotted name to the right is the object
        hierarchy within that package. Only one import is needed in this form. If
        it ends with the colon, then a module object is returned.
    
        The function will return an object (which might be a module), or raise one
        of the following exceptions:
    
        ValueError - if `name` isn't in a recognised format
        ImportError - if an import failed when it shouldn't have
        AttributeError - if a failure occurred when traversing the object hierarchy
                         within the imported package to get to the desired object.
        """
        global _NAME_PATTERN
        if _NAME_PATTERN is None:
            # Lazy import to speedup Python startup time
            import re
            dotted_words = r'(?!\d)(\w+)(\.(?!\d)(\w+))*'
            _NAME_PATTERN = re.compile(f'^(?P<pkg>{dotted_words})'
                                       f'(?P<cln>:(?P<obj>{dotted_words})?)?$',
                                       re.UNICODE)
    
        m = _NAME_PATTERN.match(name)
        if not m:
            raise ValueError(f'invalid format: {name!r}')
        gd = m.groupdict()
        if gd.get('cln'):
            # there is a colon - a one-step import is all that's needed
            mod = importlib.import_module(gd['pkg'])
            parts = gd.get('obj')
            parts = parts.split('.') if parts else []
        else:
            # no colon - have to iterate to find the package boundary
            parts = name.split('.')
            modname = parts.pop(0)
            # first part *must* be a module/package.
            mod = importlib.import_module(modname)
            while parts:
                p = parts[0]
                s = f'{modname}.{p}'
                try:
                    mod = importlib.import_module(s)
                    parts.pop(0)
                    modname = s
                except ImportError:
                    break
        # if we reach this point, mod is the module, already imported, and
        # parts is the list of parts in the object hierarchy to be traversed, or
        # an empty list if just the module is wanted.
        result = mod
        for p in parts:
>           result = getattr(result, p)
                     ^^^^^^^^^^^^^^^^^^
E           AttributeError: module 'opentelemetry.instrumentation' has no attribute 'fastapi'

../../../..../uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/pkgutil.py:715: AttributeError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

fubezz added 23 commits July 7, 2026 18:07
Adds LoggerProvider/BatchLogRecordProcessor(OTLPLogExporter()) setup to
otel_initialize(), and a loguru sink that converts each record into a
stdlib logging.LogRecord and forwards it to OTel's own LoggingHandler.

Loguru has no first-party OTel integration (this project uses loguru, not
stdlib logging, throughout), so reusing LoggingHandler's own conversion and
trace-correlation logic via a thin bridge is simpler than reimplementing
OTLP log export from scratch.

Closes the logs gap flagged in PR review against OP-3108's acceptance
criteria (traces + metrics + logs).
Splits logs export behind a new logs_enabled setting (default False),
independent of the main enabled flag. Traces/metrics behave unchanged;
the loguru->OTLP logs bridge only activates when logs_enabled is also
set, since log volume/cost characteristics differ from traces/metrics
and shouldn't be forced on every service that enables tracing.

Ref: OP-3108
Records the design decisions behind the otel_initialize()/boot() work in
this PR, plus the gitops-side shared-tools gateway/Contour/HTTPProxy/
k8sAttributes decisions from OP-3108 that motivated it.

Ref: OP-3108
The collector-push-gateway chart's own architecture (Deployment mode,
loadbalancing tier, k8s enrichment, fan-out) is the portfolio's existing
standard pattern, already running elsewhere. Reframes the first section
to make clear only the instance-to-reach and sidecar-vs-direct-export
questions are actually being decided in this ADR.
terraform-gcp-cloud-run-v2 is deprecated for Foundry. Foundry services are
built into an image and deployed via the GCP API from a generated Knative
manifest (foundry-python template + deploy-cloudrun GitHub Action), not
via that Terraform module. Corrects the sidecar-rejection reasoning and
the Direct VPC egress follow-up to point at the template's manifest
instead of a module that doesn't apply here.
Splits the single 'enabled' flag (which bundled traces+metrics together)
into three independent settings: traces_enabled and metrics_enabled
(default True once the master 'enabled' switch is on), and logs_enabled
(default False, unchanged). A service can now, for example, disable
metrics while keeping traces on, without needing all-or-nothing behavior.

Ref: OP-3108
GCP-native observability wasn't a live alternative we rejected - OP-3108
explicitly scoped this to the shared stack, and any service remains free
to point its SDK at Google's OTLP endpoint instead, since that's just an
instrumentation-level config choice. Also updates the logs-default
section and Decision text to reflect the independent traces_enabled/
metrics_enabled/logs_enabled toggles instead of one bundled flag.
… stakeholders

- Sidecar rejection now explicitly notes this matches the existing
  Sentry SDK-instrumentation pattern (in-process, not agent/sidecar-based)
  -- no precedent anywhere in the stack for a sidecar telemetry agent.
- Ingress section now states ingress-nginx is deprecated company-wide and
  Contour is the chosen long-term replacement -- not just a local
  elegance trade-off for this one gateway.
- Cert provisioning section clarified: both options result in the same
  cert-manager-issued TLS certificate: proper encryption either way, only
  the issuance mechanism differs. Added GCP-service-account authentication
  enforcement as a follow-up (not yet designed).
- Added Johannes Zorn and Arne Baumann as informed stakeholders.
Widens the scope statement to explicitly cover the ingress addition and
the gateway-instance-specific enhancements (k8sattributes disable,
optional auth), instead of just 'which instance and how'. Names the
concrete mechanism for optional ingress auth: Contour HTTPProxy's
built-in spec.virtualhost.jwtProviders (Envoy jwt_authn), which verifies
Google-issued identity tokens against Google's public JWKS -- an
available feature to turn on, not something to design from scratch.
The public app-collector instance was never a genuine alternative -- its
public exposure and VM-onboarding-specific fragility rule it out before
any real evaluation, so it's no longer framed as a considered-and-rejected
Option A/B pair. Adds the actual hard blocker for a sidecar collector:
our metrics path is scrape-only (no Thanos Receive, no real Prometheus
Pushgateway), and an ephemeral Cloud Run sidecar has no stable target for
Prometheus to scrape and nothing to push to -- its metrics would simply
never be collected.
…efit

Direct SDK export has no local buffer surviving instance teardown --
telemetry not yet flushed (BatchSpanProcessor queue, pending metric
points) can be lost on scale-to-zero/revision replacement/crash. Cloud
Run's SIGTERM grace period allows a shutdown-hook flush to mitigate most
of this, but not guarantee it. Also captures that pointing every service
at our own gateway endpoint (rather than a vendor-specific one) makes the
backend swappable later (e.g. Grafana Cloud) by reconfiguring only the
gateway's exporters.
… collector'

The existing app-collector instance serves mono/Pelago-onboarding-VM
traffic, an unrelated purpose -- it was never actually considered or
compared against for this decision, so it shouldn't be named as a
dismissed alternative. Replaced with generic references to the standard
OTel push gateway collector pattern.
Not relevant to how Foundry actually deploys Cloud Run -- describes the
real mechanism (Knative manifest + deploy-cloudrun GitHub Action)
directly without contrasting against an irrelevant module.
… rejecting it

Adds the thesis half of the dialectic that was missing: a sidecar scales
alongside each Cloud Run instance automatically, so collector capacity
tracks traffic without a separate scaling decision -- unlike a shared
gateway sized for combined load across all services. Still rejected on
the metrics-scrape blocker, but now the trade-off is stated honestly
rather than only listing downsides.
Routing through the collector (vs. any hypothetical direct push to a
metrics backend like Thanos Receive) keeps cardinality-dropping and
label normalization centralized rather than reinvented per source,
giving dashboards/alerts/on-call a common, predictable telemetry shape
regardless of where it originated.
Follow-ups was restating the same four points already explained in
Harder/risks. Trimmed to terse action items pointing back to the
existing explanation instead of repeating it.
…-of-scope

Direct VPC egress is not automatic from Shared VPC membership alone --
adds its own section detailing what's actually in place (44 projects'
enable_cloudrun_vpc_access IAM grants) vs. what's incomplete (VPC egress
annotations only render for Cloud SQL/Memorystore services; subnet
values are manual, unverified placeholders). This is the real rollout
blocker, previously understated as a one-line risk.

Also restores a trimmed authentication mention (Contour jwtProviders /
OTel-side mechanism), explicitly scoped out since the entire telemetry
chain today trusts network location only, not just this one gateway --
a valid future improvement, not something to solve as a side effect
here. Fixes dangling Follow-ups references to a since-removed section.
Previously framed as 'decouple from the condition or otherwise guarantee
it' -- now states the resolved direction plainly: every Cloud Run
service gets Direct VPC egress regardless of Cloud SQL/Memorystore
usage, since reaching shared internal services is a universal need, not
a database-specific one. Still not implemented in this PR -- tracked as
a follow-up in foundry-python.
Checked whether the manual project.hcl placeholders could just be
replaced with Terraform outputs -- they can't, directly, today:
gcp-service-projects doesn't output the subnet/network path at all, and
even if it did, the subnet isn't derivable from a fixed naming
convention fleet-wide (aignx-bridge-dev hardcodes a legacy subnet key
that doesn't follow the newer prefix-environment-env pattern). Right
first step is adding real module outputs so onboarding copies an
authoritative value instead of guessing; full automation needs a
cross-repo output-sharing mechanism that doesn't exist yet -- split into
two explicit follow-ups instead of one.
Reframed from an active follow-up to a recorded, valid improvement that
isn't being solved here -- consistent with how GCP-native observability
and ingress authentication are already handled elsewhere in this ADR.
The manual project.hcl placeholder mechanism stays as-is.
Add a Pull requests section and link foundry-python#385 and
foundry-infrastructure#10 from the Follow-ups that implement them.
Update the repo count/list in Context now that the VPC egress fix
spans the two Copier templates as well as gitops and this repo.
Fold the standalone Pull requests section into Status, next to the
Jira ticket reference, instead of a separate section.
@fubezz fubezz force-pushed the feat/otel-auto-instrumentation branch from a424026 to 3a3efe0 Compare July 9, 2026 14:16
@sonarqubecloud

sonarqubecloud Bot commented Jul 9, 2026

Copy link
Copy Markdown

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.

1 participant