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
45 changes: 25 additions & 20 deletions architecture/instruments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions lite_bootstrap/import_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 23 additions & 15 deletions lite_bootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
)
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
115 changes: 115 additions & 0 deletions planning/changes/2026-07-19.01-fix-otel-api-sdk-conflation.md
Original file line number Diff line number Diff line change
@@ -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 <module>
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.
64 changes: 13 additions & 51 deletions planning/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+)

Expand Down
28 changes: 27 additions & 1 deletion tests/instruments/test_opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()