From 1fca66e88acdb65cc733ff07572788014668fd1c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 09:39:52 +0300 Subject: [PATCH] fix(otel): split opentelemetry api/sdk presence so [fastmcp] imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_opentelemetry_installed (find_spec("opentelemetry")) is True with only opentelemetry-api, but the OTel instrument imports opentelemetry.sdk.* — so any api-only environment (e.g. lite-bootstrap[fastmcp], which pulls bare opentelemetry-api transitively) crashed `import lite_bootstrap`. Add is_opentelemetry_sdk_installed, gate the instrument's sdk imports on it, and require api+sdk in check_dependencies. Also warn (instead of silently skipping) when opentelemetry_endpoint is set but the gRPC OTLP exporter is absent. Promotes architecture/instruments.md (three-way api/sdk/exporter guard) and clears the OTel-stack dependency-model deferral. Reproduced the crash on a regular 3.12 interpreter; fixed and verified `[fastmcp]` now imports. Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/instruments.md | 45 ++++--- lite_bootstrap/import_checker.py | 5 + .../instruments/opentelemetry_instrument.py | 38 +++--- ...26-07-19.01-fix-otel-api-sdk-conflation.md | 115 ++++++++++++++++++ planning/deferred.md | 64 ++-------- .../test_opentelemetry_instrument.py | 28 ++++- 6 files changed, 208 insertions(+), 87 deletions(-) create mode 100644 planning/changes/2026-07-19.01-fix-otel-api-sdk-conflation.md diff --git a/architecture/instruments.md b/architecture/instruments.md index 109ffde..1780950 100644 --- a/architecture/instruments.md +++ b/architecture/instruments.md @@ -61,26 +61,31 @@ lite_bootstrap`. `import_checker._safe_find_spec` wraps `find_spec` for dotted names and treats that exception as absent; `is_fastapi_opentelemetry_installed` and `is_litestar_opentelemetry_installed` both route through it. -**`is_otlp_grpc_exporter_installed` is a separate guard from -`is_opentelemetry_installed`.** `is_opentelemetry_installed` only means -`opentelemetry-api` resolves — it says nothing about the SDK or the gRPC OTLP -exporter package. `opentelemetry_instrument.py` used to import the gRPC -exporter unconditionally under that coarse guard, crashing `import -lite_bootstrap` in any environment with bare `opentelemetry-api` present (e.g. -`lite-bootstrap[fastmcp]`, which pulls it in transitively without the rest of -the stack). The exporter import — and its use in `bootstrap()` — now sit behind -their own `import_checker.is_otlp_grpc_exporter_installed` guard -(`_safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")`). -The honest trade-off: `check_dependencies()` still gates OpenTelemetry -activation on `is_opentelemetry_installed` alone, so setting -`opentelemetry_endpoint` in an environment where `opentelemetry-api` is present -but the exporter package is not now **silently skips OTLP export** — -`bootstrap()`'s exporter block simply omits the span processor when -`is_otlp_grpc_exporter_installed` is False, with none of the -`InstrumentDependencyMissingWarning` a configured-but-missing dependency -normally gets. Making `check_dependencies()` distinguish api/sdk/exporter -presence, so this case gets the standard warning instead of silence, is -deferred — see `planning/deferred.md`. +**OpenTelemetry resolves as three independent distributions: api, sdk, exporter.** +`opentelemetry-api` (the `opentelemetry.trace`/`.metrics`/`.context` namespace), +`opentelemetry-sdk` (`opentelemetry.sdk.*`), and each OTLP exporter package are +separate PyPI distributions — a real environment can have any subset. Three +`import_checker` flags mirror that: + +- **`is_opentelemetry_installed`** (`find_spec("opentelemetry")`) — the api. The + six api-only consumers (logging trace-injection, framework + `get_tracer_provider`, faststream health-check spans) import only + `opentelemetry.trace`/`.metrics` and gate on this. +- **`is_opentelemetry_sdk_installed`** (`_safe_find_spec("opentelemetry.sdk")`) — + the sdk. `opentelemetry_instrument.py` imports `opentelemetry.sdk.*`, so its + module-level import block gates on this, and `check_dependencies()` requires + **both** api and sdk. Without the split, bare `opentelemetry-api` (e.g. + `lite-bootstrap[fastmcp]`, which pulls it transitively without the sdk) crashed + `import lite_bootstrap` at `from opentelemetry.sdk import resources`. +- **`is_otlp_grpc_exporter_installed`** + (`_safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")`) — + the gRPC OTLP exporter. Its import and its use in `bootstrap()` sit behind this + guard; importing it unconditionally under the api flag previously crashed + `import lite_bootstrap` the same way. When `opentelemetry_endpoint` is set but + the exporter package is absent, `bootstrap()` emits an + `InstrumentDependencyMissingWarning` ("…spans will not be exported. Install + lite-bootstrap[otl].") rather than silently omitting the span processor — the + standard configured-but-missing signal. ## Why instruments are not frozen diff --git a/lite_bootstrap/import_checker.py b/lite_bootstrap/import_checker.py index 8ed7f6e..0e26a3a 100644 --- a/lite_bootstrap/import_checker.py +++ b/lite_bootstrap/import_checker.py @@ -15,6 +15,11 @@ def _safe_find_spec(module_name: str) -> bool: is_opentelemetry_installed = find_spec("opentelemetry") is not None +# opentelemetry-api provides the `opentelemetry` namespace (trace, metrics) without the +# sdk. The OTel instrument imports opentelemetry.sdk.* and needs this stricter check; +# api-only consumers (logging trace-injection, framework get_tracer_provider) use the +# flag above. See architecture/instruments.md. +is_opentelemetry_sdk_installed = _safe_find_spec("opentelemetry.sdk") is_sentry_installed = find_spec("sentry_sdk") is not None is_structlog_installed = find_spec("structlog") is not None is_prometheus_client_installed = find_spec("prometheus_client") is not None diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index 9d3fa9d..ebf8d6c 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -6,14 +6,14 @@ import warnings from lite_bootstrap import import_checker -from lite_bootstrap.exceptions import TeardownError +from lite_bootstrap.exceptions import InstrumentDependencyMissingWarning, TeardownError from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument if typing.TYPE_CHECKING: from opentelemetry.instrumentation.instrumentor import BaseInstrumentor -if import_checker.is_opentelemetry_installed: +if import_checker.is_opentelemetry_sdk_installed: from opentelemetry.context import Context from opentelemetry.sdk import resources from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider @@ -91,7 +91,7 @@ def _parse_remote_insecure_host(self) -> str | None: return host -if import_checker.is_opentelemetry_installed and import_checker.is_pyroscope_installed: +if import_checker.is_opentelemetry_sdk_installed and import_checker.is_pyroscope_installed: _OTEL_PROFILE_ID_KEY: typing.Final = "pyroscope.profile.id" _PYROSCOPE_SPAN_ID_KEY: typing.Final = "span_id" _PYROSCOPE_SPAN_NAME_KEY: typing.Final = "span_name" @@ -131,7 +131,7 @@ class OpenTelemetryInstrument(BaseInstrument[OpenTelemetryConfig]): """ not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" + missing_dependency_message = "opentelemetry-sdk is not installed" _tracer_provider: "TracerProvider | None" = dataclasses.field( default_factory=lambda: None, init=False, repr=False, compare=False ) @@ -145,7 +145,9 @@ def is_configured(cls, bootstrap_config: "OpenTelemetryConfig") -> bool: @staticmethod def check_dependencies() -> bool: - return import_checker.is_opentelemetry_installed + # The instrument imports from both the api (opentelemetry.trace/.context) and + # the sdk (opentelemetry.sdk.*), so it needs both distributions present. + return import_checker.is_opentelemetry_installed and import_checker.is_opentelemetry_sdk_installed def _build_excluded_urls(self) -> set[str]: excluded_urls: set[str] = set(self.bootstrap_config.opentelemetry_excluded_urls) @@ -181,17 +183,23 @@ def bootstrap(self) -> None: tracer_provider.add_span_processor(PyroscopeSpanProcessor()) if self.bootstrap_config.opentelemetry_log_traces: tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(formatter=_format_span))) - if ( - self.bootstrap_config.opentelemetry_endpoint and import_checker.is_otlp_grpc_exporter_installed - ): # pragma: no cover - tracer_provider.add_span_processor( - BatchSpanProcessor( - OTLPSpanExporter( - endpoint=self.bootstrap_config.opentelemetry_endpoint, - insecure=self.bootstrap_config.opentelemetry_insecure, + if self.bootstrap_config.opentelemetry_endpoint: + if import_checker.is_otlp_grpc_exporter_installed: + tracer_provider.add_span_processor( # pragma: no cover + BatchSpanProcessor( + OTLPSpanExporter( + endpoint=self.bootstrap_config.opentelemetry_endpoint, + insecure=self.bootstrap_config.opentelemetry_insecure, + ), ), - ), - ) + ) + else: + warnings.warn( + "opentelemetry_endpoint is set but the gRPC OTLP exporter is not installed; " + "spans will not be exported. Install lite-bootstrap[otl].", + category=InstrumentDependencyMissingWarning, + stacklevel=2, + ) for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: if isinstance(one_instrumentor, InstrumentorWithParams): one_instrumentor.instrumentor.instrument( diff --git a/planning/changes/2026-07-19.01-fix-otel-api-sdk-conflation.md b/planning/changes/2026-07-19.01-fix-otel-api-sdk-conflation.md new file mode 100644 index 0000000..f3a981a --- /dev/null +++ b/planning/changes/2026-07-19.01-fix-otel-api-sdk-conflation.md @@ -0,0 +1,115 @@ +--- +summary: Split the OTel presence check into api (`is_opentelemetry_installed`) and sdk (`is_opentelemetry_sdk_installed`) so `import lite_bootstrap` no longer crashes in an api-only environment (e.g. `lite-bootstrap[fastmcp]`); gate the OTel instrument's sdk imports + `check_dependencies` on sdk, and warn (instead of silently skipping) when an OTLP endpoint is set without the gRPC exporter installed. +--- + +# Design: Fix the opentelemetry api/sdk/exporter conflation + +## Summary + +`is_opentelemetry_installed = find_spec("opentelemetry")` is `True` with only +`opentelemetry-api` installed, but three sites in `OpenTelemetryInstrument` +import or require `opentelemetry.sdk.*`. Any environment with the api package but +not the sdk — notably `lite-bootstrap[fastmcp]`, whose deps pull bare +`opentelemetry-api` — crashes on `import lite_bootstrap`. Introduce a +correctly-scoped `is_opentelemetry_sdk_installed` flag, gate the three sdk sites +on it, and (a related consistency fix) warn instead of silently skipping when an +OTLP endpoint is configured but the gRPC exporter package is absent. + +## Motivation + +Reproduced on regular (GIL) CPython 3.12, `uv pip install "lite-bootstrap[fastmcp]"`: + +``` +File ".../instruments/opentelemetry_instrument.py", line 18, in + from opentelemetry.sdk import resources +ModuleNotFoundError: No module named 'opentelemetry.sdk' +``` + +`opentelemetry` (api) and `opentelemetry.trace`/`.metrics` are present; +`opentelemetry.sdk` is absent. `lite-bootstrap[fastmcp]` without `otl` has never +been importable — not ft-specific. (This is the third of three +incomplete-opentelemetry-stack bugs; the api-namespace crash and the unconditional +gRPC-exporter import were fixed in `2026-07-18.01`. It was deferred there and is +now picked up.) + +## Design + +### 1. Two presence flags, precisely scoped + +`import_checker.py`: add + +```python +is_opentelemetry_sdk_installed = _safe_find_spec("opentelemetry.sdk") +``` + +Keep `is_opentelemetry_installed` (api) for the **six api-only consumers** whose +guarded blocks import only `opentelemetry.trace`/`.metrics` (all provided by +`opentelemetry-api`): `logging_instrument.py:30`, `fastapi_bootstrapper.py:30`, +`litestar_bootstrapper.py:46`, `faststream_bootstrapper.py:27,102`, and the +`is_{fastapi,litestar}_opentelemetry_installed` derivations. + +`opentelemetry_instrument.py`: switch the **three sdk sites** to the new flag: + +- the module-level import block (`opentelemetry.sdk.resources`, + `opentelemetry.sdk.trace.*`) — the crash site; +- the `PyroscopeSpanProcessor(SpanProcessor)` block (`SpanProcessor` is sdk); +- `check_dependencies()` → `return import_checker.is_opentelemetry_sdk_installed`. + +Rejected: redefining `is_opentelemetry_installed` to mean sdk — it would break the +six api-only features whenever api is present without sdk (log trace-injection, +faststream health-check spans, framework `get_tracer_provider`). Those genuinely +need only the api. + +### 2. `check_dependencies` now reflects sdk + +Because the instrument requires the sdk, `check_dependencies()` returning sdk +presence means the standard construction flow (`is_configured` → +`check_dependencies` → warn+skip) fires the existing +`InstrumentDependencyMissingWarning` when OTel is configured but only the api is +present — a warning, not a crash. + +### 3. Warn on a configured endpoint without the gRPC exporter + +`bootstrap()` currently silently no-ops the OTLP exporter when +`opentelemetry_endpoint` is set but `is_otlp_grpc_exporter_installed` is `False`. +Replace the silent skip with a warning: + +```python +if self.bootstrap_config.opentelemetry_endpoint: + if import_checker.is_otlp_grpc_exporter_installed: + tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(...))) # pragma: no cover + else: + warnings.warn( + "opentelemetry_endpoint is set but the gRPC OTLP exporter is not installed; " + "spans will not be exported. Install lite-bootstrap[otl].", + category=InstrumentDependencyMissingWarning, + stacklevel=2, + ) +``` + +## Non-goals + +- **A config-aware `check_dependencies(config)`.** The endpoint→exporter + relationship is config-dependent, so the warning lives in `bootstrap()` rather + than reworking the base `check_dependencies()` staticmethod contract. +- **Re-adding `fastmcp` to the ft CI leg.** Now unblocked on 3.14t, but a separate + CI-scope decision (`cffi` still blocks 3.13t). Tracked in `deferred.md`. + +## Testing + +- **Regression (crash):** with `opentelemetry.sdk` emulated absent + (`emulate_package_missing` + module reload, as in `2026-07-18.01`'s tests), + `is_opentelemetry_sdk_installed is False`, the instrument module reimports + cleanly, and `check_dependencies()` is `False`. Fails on current code + (`ModuleNotFoundError`), green after. +- **Warn branch:** endpoint configured + `is_otlp_grpc_exporter_installed` + monkeypatched `False` → `bootstrap()` emits `InstrumentDependencyMissingWarning` + (removes the previously-untested silent skip; the true exporter branch keeps its + live-collector `# pragma: no cover`). +- `just test` 100% coverage; `just lint-ci` clean. + +## Risk + +- **Behaviour change for a partial-otel install (low × low).** Configuring OTel + with only the api, or an endpoint without the exporter, now warns instead of + crashing/silently-skipping — strictly better, and pinned by the two tests. diff --git a/planning/deferred.md b/planning/deferred.md index 24cc3ea..2faafeb 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -42,57 +42,19 @@ verification commands, for two separate, independent reasons — one per leg: build-environment problem, the same shape as msgspec's 3.13t gate below. Reproduced 2026-07-18: `uv pip install --python <3.13t venv> ".[fastmcp]"` fails building `cffi`. -- **3.14t (import-time, partially fixed, one blocker remains):** `fastmcp` - transitively pulls bare `opentelemetry-api` with no other `opentelemetry-*` - package (independent of lite-bootstrap's own `otl` extra, which bundles - api+sdk+exporter+instrumentation together and is excluded from ft for the - unrelated grpcio reason above). Two import-safety bugs this exposed are now - fixed: `import_checker.py`'s dotted `find_spec` calls previously raised - `ModuleNotFoundError` instead of returning `False` when a parent namespace - was incomplete, and `opentelemetry_instrument.py` previously imported the - grpc otlp exporter unconditionally whenever bare `opentelemetry-api` was - present. A third, deeper issue remains, found while verifying the above: - `opentelemetry_instrument.py` also imports several `opentelemetry.sdk.*` - names under the same `is_opentelemetry_installed` guard, and - `check_dependencies()` uses that same flag — but `opentelemetry-sdk` is a - separate PyPI distribution that `fastmcp` does not pull in, so `uv pip - install ".[fastmcp]"` followed by `import lite_bootstrap` still raises - `ModuleNotFoundError: No module named 'opentelemetry.sdk'` (reproduced - 2026-07-18 on 3.14t). See "OTel-stack dependency model" below for the fix - shape and a second, independent symptom of the same root cause. - -**Trigger:** `cffi` ships free-threaded 3.13 wheels (unblocks 3.13t), or fastmcp -support on the 3.14t leg is wanted (needs the `opentelemetry-sdk`-vs-api -activation fix below first). - -### OTel-stack dependency model (api vs sdk vs exporter granularity) - -`is_opentelemetry_installed` is one `find_spec("opentelemetry")` check, but -`opentelemetry-api`, `opentelemetry-sdk`, and the OTLP exporter packages are -three independent PyPI distributions — a real environment can have any subset -installed. `check_dependencies()`/activation only sees that one coarse flag, -which produces two distinct symptoms: - -- **Import-time:** `opentelemetry_instrument.py` imports several - `opentelemetry.sdk.*` names under the same `is_opentelemetry_installed` - guard used for the now-fixed exporter-import bug (see the fastmcp entry - above), so `import lite_bootstrap` still crashes with `ModuleNotFoundError: - No module named 'opentelemetry.sdk'` whenever `opentelemetry-api` is present - without `opentelemetry-sdk` — the case for `lite-bootstrap[fastmcp]`. -- **Activation-time:** `check_dependencies()` gates OpenTelemetry activation on - `is_opentelemetry_installed` alone, so an environment with `opentelemetry-api` - present but the OTLP exporter package absent passes `check_dependencies()` - cleanly, and `bootstrap()`'s `opentelemetry_endpoint`-gated span-processor - block silently no-ops once `is_otlp_grpc_exporter_installed` is False — - skipping OTLP export without the `InstrumentDependencyMissingWarning` a - configured-but-missing dependency normally gets elsewhere. See - `architecture/instruments.md`'s "Optional-dependency guard" section. - -Proper fix: an exporter/sdk-aware `check_dependencies()` (and matching -warning) that distinguishes api/sdk/exporter presence instead of one bool — -an activation-semantics change, not just another import guard. -**Trigger:** `[fastmcp]`-without-`otl` support is wanted (needs this first), or -a user reports the silent OTLP-export skip. +- **3.14t (now importable):** `fastmcp` transitively pulls bare + `opentelemetry-api` with no other `opentelemetry-*` package. The three + incomplete-opentelemetry-stack bugs this exposed are all fixed — the + `import_checker` namespace crash and the unconditional grpc-exporter import in + `changes/2026-07-18.01`, and the api-vs-sdk conflation + (`opentelemetry_instrument.py` importing `opentelemetry.sdk.*` and + `check_dependencies()` under the api-only flag) in `changes/2026-07-19.01`. So + `uv pip install ".[fastmcp]"` + `import lite_bootstrap` now succeeds on 3.14t; + it is simply not yet wired into the 3.14t leg's extras. + +**Trigger:** `cffi` ships free-threaded 3.13 wheels (unblocks 3.13t), **or** add +`fastmcp`/`fastmcp-metrics` to the 3.14t leg's extras in `_checks.yml` (import +now works there). ### litestar on free-threaded Python 3.13 (msgspec gates Py_GIL_DISABLED to 3.14+) diff --git a/tests/instruments/test_opentelemetry_instrument.py b/tests/instruments/test_opentelemetry_instrument.py index b4c5f26..2afd206 100644 --- a/tests/instruments/test_opentelemetry_instrument.py +++ b/tests/instruments/test_opentelemetry_instrument.py @@ -8,7 +8,7 @@ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from lite_bootstrap import import_checker -from lite_bootstrap.exceptions import TeardownError +from lite_bootstrap.exceptions import InstrumentDependencyMissingWarning, TeardownError from lite_bootstrap.instruments.opentelemetry_instrument import ( InstrumentorWithParams, OpenTelemetryConfig, @@ -213,3 +213,29 @@ def test_opentelemetry_instrument_survives_missing_grpc_exporter() -> None: assert import_checker.is_otlp_grpc_exporter_installed is False finally: sys.modules.update(saved_submodules) + + +def test_opentelemetry_instrument_survives_missing_sdk() -> None: + # opentelemetry-api present but opentelemetry-sdk absent (e.g. lite-bootstrap[fastmcp], + # which pulls bare opentelemetry-api transitively) must not crash importing + # opentelemetry_instrument, whose guarded block imports opentelemetry.sdk.* symbols. + with emulate_package_missing_with_module_reload( + "opentelemetry.sdk", + ["lite_bootstrap.instruments.opentelemetry_instrument"], + ): + assert import_checker.is_opentelemetry_sdk_installed is False + assert OpenTelemetryInstrument.check_dependencies() is False + + +def test_bootstrap_warns_when_endpoint_set_without_grpc_exporter() -> None: + # SDK present but the grpc exporter absent, with an endpoint configured: bootstrap() + # must warn (configured-but-missing) rather than silently skip OTLP export. + instrument = OpenTelemetryInstrument(bootstrap_config=OpenTelemetryConfig(opentelemetry_endpoint="localhost:4317")) + try: + with ( + patch.object(import_checker, "is_otlp_grpc_exporter_installed", False), + pytest.warns(InstrumentDependencyMissingWarning, match="gRPC OTLP exporter"), + ): + instrument.bootstrap() + finally: + instrument.teardown()