Skip to content

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

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

feat(otel): initialize OpenTelemetry tracing and metrics in boot()#96
fubezz wants to merge 3 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
438 1 437 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.145s run time
self = <tests.aignostics_foundry_core.otel_test.TestInstrumentFastapi object at 0x7f58f320c610>

    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:234: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../../..../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 2 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
@sonarqubecloud

sonarqubecloud Bot commented Jul 7, 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