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
6 changes: 3 additions & 3 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ jobs:
matrix:
include:
- python-version: "3.13t"
extras: "logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics"
extras: "logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics,otl-http"
- python-version: "3.14t"
extras: "logging,sentry,fastapi,litestar,faststream,fastmcp,fastapi-metrics,litestar-metrics,faststream-metrics,fastmcp-metrics"
extras: "logging,sentry,fastapi,litestar,faststream,fastmcp,fastapi-metrics,litestar-metrics,faststream-metrics,fastmcp-metrics,otl-http"
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.2.0
Expand All @@ -65,7 +65,7 @@ jobs:
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install ${{ matrix.python-version }}
- run: uv venv --python ${{ matrix.python-version }}
- name: Install core + ft-ready extras (orjson/otl/pyroscope excluded; litestar/fastmcp 3.14t-only)
- name: Install core + ft-ready extras (orjson/otl-grpc/pyroscope excluded; otl-http included; litestar/fastmcp 3.14t-only)
run: uv pip install ".[${{ matrix.extras }}]"
- name: Free-threaded smoke test
run: .venv/bin/python scripts/ft_smoke.py
12 changes: 7 additions & 5 deletions architecture/free-threading.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ its extra, not `lite-bootstrap` itself.
| `litestar` (+ `litestar-metrics`) | ❌ | ✅ | `msgspec` gates `Py_GIL_DISABLED` to Python 3.14+ — its `_core.c` contains `#error "Py_GIL_DISABLED is only supported in Python 3.14+"` (v0.21.1), so the source build fails on 3.13t. See [`planning/deferred.md`](../planning/deferred.md) |
| `fastmcp` (+ `fastmcp-metrics`) | ❌ | ✅ | 3.13t: `cffi` (via `fastmcp`→`cryptography`) refuses to build free-threaded. 3.14t: works (on the leg) since the opentelemetry api/sdk split. See [`planning/deferred.md`](../planning/deferred.md) |
| `orjson` (opt-in speedup) | ❌ | ❌ | no ft wheels, build refuses ft ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530)). Omit it on ft; the serializer falls back to stdlib json |
| `otl` (gRPC exporter) | ❌ | ❌ | needs `grpcio`, no ft wheels ([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)). An HTTP-exporter path is deferred (`planning/deferred.md`) |
| `otl` (gRPC exporter) | ❌ | ❌ | needs `grpcio`, no ft wheels ([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)) |
| `otl-http` (HTTP exporter) | ✅ | ✅ | `opentelemetry-exporter-otlp-proto-http` (requests + protobuf, no grpcio); set `opentelemetry_exporter_protocol="http"` |
| `pyroscope` | ❌ | ❌ | `pyroscope-io` is abi3-only, unmaintained, no ft wheels ([`planning/deferred.md`](../planning/deferred.md)) |

The CI matrix (`.github/workflows/_checks.yml`, `free-threaded` job) installs
per Python version to match this table exactly: the 3.13t leg's extras stop at
`logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics`; the
3.14t leg adds `litestar,litestar-metrics,fastmcp,fastmcp-metrics`. `orjson`,
`otl`, and `pyroscope` are excluded from both legs; `fastmcp` runs on 3.14t only
(3.13t is `cffi`-blocked).
`logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics,otl-http`;
the 3.14t leg adds `litestar,litestar-metrics,fastmcp,fastmcp-metrics`. `orjson`
and `pyroscope` are excluded from both legs, and `otl` (the gRPC exporter) is
excluded in favor of `otl-http`, which is included on both legs; `fastmcp` runs
on 3.14t only (3.13t is `cffi`-blocked).

## The `orjson` fallback

Expand Down
14 changes: 14 additions & 0 deletions architecture/instruments.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ separate PyPI distributions — a real environment can have any subset. Three
`InstrumentDependencyMissingWarning` ("…spans will not be exported. Install
lite-bootstrap[otl].") rather than silently omitting the span processor — the
standard configured-but-missing signal.
- **`is_otlp_http_exporter_installed`**
(`_safe_find_spec("opentelemetry.exporter.otlp.proto.http.trace_exporter")`) —
the HTTP OTLP exporter (`opentelemetry-exporter-otlp-proto-http`, no `grpcio`,
so it installs on free-threaded builds; see `architecture/free-threading.md`).
Same guarded-import/guarded-use shape as the gRPC flag above; the missing-package
warning names `lite-bootstrap[otl-http]` instead.

`OpenTelemetryConfig.opentelemetry_exporter_protocol` (`"grpc"` default | `"http"`)
selects which exporter `bootstrap()` builds when `opentelemetry_endpoint` is set:
`"grpc"` passes `endpoint`/`insecure` to `OTLPGrpcSpanExporter`; `"http"` passes
only `endpoint` (a full URL) to `OTLPHttpSpanExporter` — the HTTP exporter has no
`insecure` parameter, since that's carried by the URL scheme. Each branch checks
its own installed-flag and warns independently if the corresponding exporter
package is missing.

## Why instruments are not frozen

Expand Down
5 changes: 3 additions & 2 deletions docs/introduction/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ Additional parameters:

- `opentelemetry_service_name` - if provided, will be passed to the `Resource` instead of `service_name`.
- `opentelemetry_container_name` - will be passed to the `Resource`.
- `opentelemetry_endpoint` - will be passed to `OTLPSpanExporter` as endpoint.
- `opentelemetry_endpoint` - will be passed to `OTLPSpanExporter` as endpoint. Under `opentelemetry_exporter_protocol="http"` this is a full URL (e.g. `http://collector:4318/v1/traces`).
- `opentelemetry_namespace` - will be passed to the `Resource`.
- `opentelemetry_insecure` - is opentelemetry connection secure.
- `opentelemetry_exporter_protocol` - OTLP exporter transport: `"grpc"` (default, needs the `otl` extra) or `"http"` (needs the `otl-http` extra, no `grpcio` - installable on free-threaded Python).
- `opentelemetry_insecure` - whether the gRPC OTLP connection is insecure (gRPC only; for `http` the endpoint URL scheme carries security).
- `opentelemetry_instrumentors` - a list of extra instrumentors.
- `opentelemetry_log_traces` - traces will be logged to stdout.
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`.
Expand Down
1 change: 1 addition & 0 deletions lite_bootstrap/import_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def _safe_find_spec(module_name: str) -> bool:
is_opentelemetry_installed and is_litestar_installed and _safe_find_spec("opentelemetry.instrumentation.asgi")
)
is_otlp_grpc_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")
is_otlp_http_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.http.trace_exporter")
is_pyroscope_installed = find_spec("pyroscope") is not None
is_fastmcp_installed = find_spec("fastmcp") is not None
is_orjson_installed = find_spec("orjson") is not None
38 changes: 29 additions & 9 deletions lite_bootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
# opentelemetry-api can be present without the grpc otlp exporter package (e.g.
# lite-bootstrap[fastmcp] pulls bare opentelemetry-api transitively); this must
# stay a separate guard from is_opentelemetry_installed above.
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as OTLPGrpcSpanExporter

if import_checker.is_otlp_http_exporter_installed:
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as OTLPHttpSpanExporter

if import_checker.is_pyroscope_installed:
import pyroscope
Expand Down Expand Up @@ -56,6 +59,7 @@ class OpenTelemetryConfig(OpenTelemetryServiceFieldsConfig):
)
opentelemetry_endpoint: str | None = None
opentelemetry_insecure: bool = True
opentelemetry_exporter_protocol: typing.Literal["grpc", "http"] = "grpc"
opentelemetry_instrumentors: list[typing.Union[InstrumentorWithParams, "BaseInstrumentor"]] = dataclasses.field(
default_factory=list
)
Expand All @@ -75,6 +79,8 @@ def __post_init__(self) -> None:

def _parse_remote_insecure_host(self) -> str | None:
"""Return the host name if the endpoint is insecure AND non-local; else None."""
if self.opentelemetry_exporter_protocol != "grpc":
return None
if not self.opentelemetry_endpoint or not self.opentelemetry_insecure:
return None
if self.opentelemetry_endpoint.startswith("unix://"):
Expand Down Expand Up @@ -184,19 +190,33 @@ def bootstrap(self) -> None:
if self.bootstrap_config.opentelemetry_log_traces:
tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(formatter=_format_span)))
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,
if self.bootstrap_config.opentelemetry_exporter_protocol == "grpc":
if import_checker.is_otlp_grpc_exporter_installed:
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPGrpcSpanExporter(
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,
)
elif import_checker.is_otlp_http_exporter_installed:
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPHttpSpanExporter(endpoint=self.bootstrap_config.opentelemetry_endpoint),
),
)
else:
warnings.warn(
"opentelemetry_endpoint is set but the gRPC OTLP exporter is not installed; "
"spans will not be exported. Install lite-bootstrap[otl].",
"opentelemetry_endpoint is set but the HTTP OTLP exporter is not installed; "
"spans will not be exported. Install lite-bootstrap[otl-http].",
category=InstrumentDependencyMissingWarning,
stacklevel=2,
)
Expand Down
122 changes: 122 additions & 0 deletions planning/changes/2026-07-19.02-otlp-http-exporter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
summary: Add an `opentelemetry_exporter_protocol` config (grpc|http, default grpc) and an `otl-http` extra (opentelemetry-exporter-otlp-proto-http, no grpcio) so OTLP trace export works on free-threaded Python; repoint `otl` to the grpc exporter package.
---

# Design: OTLP HTTP exporter option (free-threaded OTLP export)

## Summary

The `otl` extra is the last observability surface with no free-threaded story:
its OTLP exporter goes over gRPC, which needs `grpcio`, which ships no
free-threaded wheels. OpenTelemetry also offers an **HTTP/protobuf** exporter
(`requests` + `protobuf`, no grpcio) that talks to the same collectors. Add an
`opentelemetry_exporter_protocol` config knob (default `"grpc"`, unchanged
behavior) and an `otl-http` extra so a free-threaded service can export traces.

## Motivation

`otl` installs `opentelemetry-exporter-otlp` (a meta package pulling **both** the
grpc exporter → `grpcio` and the http exporter), so `pip install
lite-bootstrap[otl]` cannot resolve on a free-threaded interpreter
([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)). The instrument
hardwired the gRPC exporter. The HTTP exporter
(`opentelemetry-exporter-otlp-proto-http`) has no `grpcio` dependency (verified:
`requests`, `opentelemetry-proto`, `googleapis-common-protos`), so it installs
on ft. This was deferred from the 2026-07-14 free-threading work; picking it up.

## Design

### 1. Config: exporter protocol

```python
# OpenTelemetryConfig
opentelemetry_exporter_protocol: typing.Literal["grpc", "http"] = "grpc"
```

Default `"grpc"` preserves current behavior exactly. `opentelemetry_insecure`
applies to gRPC only; for HTTP the endpoint is a full URL
(`http://collector:4318/v1/traces`) whose scheme carries security — documented,
not warned (see `decisions/2026-07-19-otlp-http-exporter-shape.md`).

### 2. Two guarded, aliased exporter imports

`import_checker.py`: add
`is_otlp_http_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.http.trace_exporter")`
alongside the existing `is_otlp_grpc_exporter_installed`.

`opentelemetry_instrument.py`: import each under its own guard, aliased to avoid
the shared `OTLPSpanExporter` name:

```python
if import_checker.is_otlp_grpc_exporter_installed:
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as OTLPGrpcSpanExporter
if import_checker.is_otlp_http_exporter_installed:
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as OTLPHttpSpanExporter
```

### 3. Protocol-selecting `bootstrap()`

```python
if self.bootstrap_config.opentelemetry_endpoint:
if self.bootstrap_config.opentelemetry_exporter_protocol == "grpc":
if import_checker.is_otlp_grpc_exporter_installed:
tracer_provider.add_span_processor( # pragma: no cover (live collector)
BatchSpanProcessor(OTLPGrpcSpanExporter(endpoint=..., insecure=...)))
else:
warnings.warn("… gRPC OTLP exporter is not installed … install lite-bootstrap[otl].", …)
elif import_checker.is_otlp_http_exporter_installed:
tracer_provider.add_span_processor( # pragma: no cover (live collector)
BatchSpanProcessor(OTLPHttpSpanExporter(endpoint=self.bootstrap_config.opentelemetry_endpoint)))
else:
warnings.warn("… HTTP OTLP exporter is not installed … install lite-bootstrap[otl-http].", …)
```

The exporter-construction lines keep `# pragma: no cover` (need a live
collector); both missing-exporter warn branches are covered by tests.

### 4. Extras

`pyproject.toml`: repoint `otl` from the meta package to the grpc exporter, and
add `otl-http`:

```toml
otl = ["opentelemetry-api", "opentelemetry-sdk", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-instrumentation"]
otl-http = ["opentelemetry-api", "opentelemetry-sdk", "opentelemetry-exporter-otlp-proto-http", "opentelemetry-instrumentation"]
```

`otl` users are unaffected functionally (it already defaulted to gRPC and never
used the http exporter). No framework `*-otl-http` variants — a free-threaded
framework service composes `[fastapi, otl-http]` and adds its instrumentation
package if it wants auto-instrumentation.

### 5. CI + proof

Add `otl-http` to both ft legs' extras in `_checks.yml` (proves it resolves on
3.13t/3.14t). Extend `scripts/ft_smoke.py` to bootstrap an
`OpenTelemetryInstrument` with `opentelemetry_exporter_protocol="http"` and an
endpoint, then tear down — proving the http export path constructs on ft with no
live collector needed.

## Non-goals

- **Framework `*-otl-http` extras.** Avoided to keep the extras matrix small.
- **Changing the default protocol.** Stays `grpc` for backward compatibility.
- **An `insecure`-style flag or http:// warning for HTTP.** The URL scheme
carries security; documented, per the decision file.

## Testing

- Unit (TDD): with `opentelemetry_exporter_protocol="http"` + an endpoint,
`bootstrap()` constructs the **http** exporter (patch `OTLPHttpSpanExporter`,
assert called with the endpoint); `"grpc"` still constructs the grpc exporter;
the http-absent and grpc-absent branches each emit
`InstrumentDependencyMissingWarning` naming the right extra.
- ft CI leg installs `otl-http` and `scripts/ft_smoke.py` bootstraps a
protocol=http instrument on 3.13t/3.14t.
- `just test` 100% coverage; `just lint-ci` clean.

## Risk

- **`otl` dependency-set change (low × low).** From the `opentelemetry-exporter-otlp`
meta to `opentelemetry-exporter-otlp-proto-grpc`. Same grpc exporter, minus the
unused http package; grpc behavior unchanged. Noted in the release note.
47 changes: 47 additions & 0 deletions planning/decisions/2026-07-19-otlp-http-exporter-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
status: accepted
summary: otl repoints to the grpc exporter package and a new otl-http adds the http exporter (no framework variants); the insecure warning stays gRPC-only (http security is the endpoint URL scheme).
supersedes: null
superseded_by: null
---

# OTLP HTTP exporter: extras shape and http security signal

**Decision:** `otl` → `opentelemetry-exporter-otlp-proto-grpc`; new `otl-http` →
`opentelemetry-exporter-otlp-proto-http` (no grpcio); no framework `*-otl-http`
variants. The `__post_init__` insecure warning stays tied to the gRPC `insecure`
flag; for HTTP, the endpoint URL scheme (`http://` vs `https://`) carries
security and is documented, not warned.

## Context

`otl` bundled `opentelemetry-exporter-otlp` (a meta package pulling grpc→grpcio
and http). To make OTLP export work on free-threaded Python (no grpcio wheels),
the exporter must be selectable and the http path must be installable without
grpcio. Two shape questions arose.

## Decision & rationale

**Extras — `otl`=grpc, `otl-http`=http, base only.** Repointing `otl` to the
grpc exporter package is functionally identical for existing users (it already
defaulted to gRPC and never used the http exporter) and drops only the unused,
transitively-bundled http package. `otl-http` carries the grpcio-free http
exporter for ft. Rejected: keeping `otl` as the meta and layering `otl-http`
on top — leaves `otl` grpcio-bound and redundant (already ships http). Rejected:
framework `*-otl-http` variants (`fastapi-otl-http`, …) — the same combinatorial
extras sprawl avoided in the free-threading work; a ft framework service composes
`[fastapi, otl-http]` and adds its instrumentation package directly.

**HTTP security signal — no warning.** The gRPC exporter has an `insecure` bool
that the config warns about for non-local endpoints. The HTTP exporter has no
such flag; its endpoint is a full URL whose scheme is the security signal.
Re-deriving an "insecure" state by parsing `http://` vs `https://` would add
scheme-parsing to `__post_init__` for a signal the user already controls
explicitly in the URL. Documented instead. Rejected: an http:// non-local
warning — more code for a weaker nudge than the URL itself already gives.

## Revisit trigger

A user asks for a framework-specific ft OTLP-http convenience extra, or for the
http endpoint to accept a bare `host:port` (auto-building the URL) — either would
reopen the extras/URL-handling shape.
12 changes: 0 additions & 12 deletions planning/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,6 @@ change file in [`changes/active/`](changes/active/); see [CLAUDE.md](../CLAUDE.m

## Open

### OTLP export on free-threaded Python (HTTP exporter path)

The `otl` extra pulls `grpcio` (via the gRPC OTLP exporter), which has no
free-threaded wheels, so `otl` is uninstallable on ft. `opentelemetry_instrument.py`
hardwires the gRPC exporter (`from opentelemetry.exporter.otlp.proto.grpc...`).
Fix would add an `opentelemetry_exporter_protocol` field (grpc|http) + an
`otl-http` extra on `opentelemetry-exporter-otlp-proto-http` (no grpcio; protobuf
falls back to pure-python). Deferred from the 2026-07-18 free-threading change to
keep that change orjson-only.
**Trigger:** a user needs OTLP trace export on ft, **or** `grpcio` ships ft wheels
([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)) making the swap moot.

### Pyroscope on free-threaded Python

`pyroscope-io` is abi3-only, unmaintained, ships no ft wheels and has no pure
Expand Down
Loading
Loading