Skip to content

Commit 6cb8322

Browse files
authored
feat: add an opt-in OpenTelemetry metrics pipeline (#265)
1 parent d118c6a commit 6cb8322

11 files changed

Lines changed: 356 additions & 20 deletions

‎docs/introduction/configuration.md‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,10 @@ Additional parameters for FastAPI integration:
9999

100100
## Opentelemetry
101101

102-
To bootstrap Opentelemetry, you must provide at least:
102+
To bootstrap Opentelemetry, you must provide at least one of:
103103

104-
- `opentelemetry_endpoint`.
104+
- `opentelemetry_endpoint`, for traces.
105+
- `opentelemetry_metrics_endpoint`, for metrics.
105106

106107
Additional parameters:
107108

@@ -116,6 +117,29 @@ Additional parameters:
116117
- `opentelemetry_sampler` - an `opentelemetry.sdk.trace.sampling.Sampler` deciding which traces are recorded. Unset, the SDK's own default applies: `parentbased_always_on`, which records every trace that is not the child of a non-recording remote parent, unless `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` say otherwise.
117118
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`.
118119
- `opentelemetry_excluded_urls` - extra URLs excluded from tracing; the metrics path and (unless health-check spans are enabled) the health-check path are excluded automatically.
120+
- `opentelemetry_metrics_endpoint` - will be passed to `OTLPMetricExporter` as endpoint, and turns on the metrics signal. Its own field rather than a flag on `opentelemetry_endpoint`, so upgrading never starts exporting metrics you did not ask for. Under `opentelemetry_exporter_protocol="http"` this is a full URL (e.g. `http://collector:4318/v1/metrics`); `opentelemetry_exporter_protocol` and `opentelemetry_insecure` are shared with traces.
121+
122+
### Metrics
123+
124+
Left unset, no `MeterProvider` is installed and the metrics signal stays off. Set it, and the
125+
FastAPI, Litestar and FastStream instrumentations start recording their duration histograms
126+
against it; until then they build those instruments against a no-op provider and the data goes
127+
nowhere.
128+
129+
The export interval is the SDK's, 60 seconds, overridable with `OTEL_METRIC_EXPORT_INTERVAL`.
130+
131+
If the Prometheus instrument is also configured, request metrics are recorded twice: once for the
132+
scrape endpoint and once for the OTLP pipeline, under different names. That is supported, and it
133+
is what you want when the two feed different backends, but pointing both at one backend
134+
double-counts.
135+
136+
```python
137+
config = FastAPIConfig(
138+
service_name="my-service",
139+
opentelemetry_endpoint="localhost:4317",
140+
opentelemetry_metrics_endpoint="localhost:4317",
141+
)
142+
```
119143

120144
Sampling is the cheapest way to cut what tracing costs: on a benchmark endpoint returning a constant,
121145
`ParentBased(TraceIdRatioBased(0.01))` saved ~55 µs per request against the always-on default.

‎lite_bootstrap/bootstrappers/fastapi_bootstrapper.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
fastapi_access_logger: typing.Final = structlog.get_logger("http.access")
3838

3939
if import_checker.is_opentelemetry_installed:
40+
from opentelemetry.metrics import get_meter_provider
4041
from opentelemetry.trace import get_tracer_provider
4142

4243
if import_checker.is_fastapi_opentelemetry_installed:
@@ -200,6 +201,7 @@ def bootstrap(self) -> None:
200201
FastAPIInstrumentor.instrument_app(
201202
app=self.bootstrap_config.app,
202203
tracer_provider=get_tracer_provider(),
204+
meter_provider=get_meter_provider(),
203205
excluded_urls=",".join(self._build_excluded_urls()),
204206
exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans,
205207
)

‎lite_bootstrap/bootstrappers/faststream_bootstrapper.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
if import_checker.is_opentelemetry_installed:
2828
from opentelemetry import trace
29-
from opentelemetry.metrics import Meter, MeterProvider
29+
from opentelemetry.metrics import Meter, MeterProvider, get_meter_provider
3030
from opentelemetry.trace import TracerProvider, get_tracer_provider
3131

3232
tracer: typing.Final = trace.get_tracer(__name__)
@@ -149,7 +149,9 @@ def bootstrap(self) -> None:
149149
config = self.bootstrap_config
150150
if config.opentelemetry_middleware_cls and config.application.broker:
151151
config.application.broker.add_middleware(
152-
config.opentelemetry_middleware_cls(tracer_provider=get_tracer_provider())
152+
config.opentelemetry_middleware_cls(
153+
tracer_provider=get_tracer_provider(), meter_provider=get_meter_provider()
154+
)
153155
)
154156

155157

‎lite_bootstrap/bootstrappers/litestar_bootstrapper.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@
5151
from litestar.middleware import ASGIMiddleware
5252
from litestar.types.asgi_types import ASGIApp, Receive, Scope, Send
5353
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
54+
from opentelemetry.metrics import MeterProvider
5455
from opentelemetry.trace import TracerProvider
5556
from opentelemetry.util.http import parse_excluded_urls
5657

5758
if import_checker.is_opentelemetry_installed:
59+
from opentelemetry.metrics import get_meter_provider
5860
from opentelemetry.trace import get_tracer_provider
5961

6062
if import_checker.is_structlog_installed:
@@ -101,8 +103,11 @@ def build_litestar_route_details_from_scope(
101103
if import_checker.is_litestar_opentelemetry_installed:
102104

103105
class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware):
104-
def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None:
106+
def __init__(
107+
self, tracer_provider: "TracerProvider", meter_provider: "MeterProvider", excluded_urls: set[str]
108+
) -> None:
105109
self._tracer_provider = tracer_provider
110+
self._meter_provider = meter_provider
106111
# OpenTelemetryMiddleware only parses a raw string from 0.56b0; the floor is 0.49b0.
107112
self._excluded_urls = parse_excluded_urls(",".join(excluded_urls))
108113
# WeakKeyDictionary so wrapper apps are evicted when Litestar drops the
@@ -126,6 +131,7 @@ async def handle(
126131
default_span_details=build_litestar_route_details_from_scope,
127132
excluded_urls=self._excluded_urls,
128133
tracer_provider=self._tracer_provider,
134+
meter_provider=self._meter_provider,
129135
)
130136
with contextlib.suppress(TypeError):
131137
self._otel_apps[next_app] = otel_app # ty: ignore[invalid-assignment]
@@ -256,6 +262,7 @@ def bootstrap(self) -> None:
256262
self.bootstrap_config.application_config.middleware.append(
257263
LitestarOpenTelemetryInstrumentationMiddleware(
258264
tracer_provider=get_tracer_provider(),
265+
meter_provider=get_meter_provider(),
259266
excluded_urls=self._build_excluded_url_patterns(),
260267
)
261268
)

‎lite_bootstrap/instruments/opentelemetry_instrument.py‎

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616

1717
if import_checker.is_opentelemetry_sdk_installed:
1818
from opentelemetry.context import Context
19+
from opentelemetry.metrics import get_meter_provider, set_meter_provider
1920
from opentelemetry.sdk import resources
21+
from opentelemetry.sdk.metrics import MeterProvider
22+
from opentelemetry.sdk.metrics.export import MetricExporter, PeriodicExportingMetricReader
2023
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider
2124
from opentelemetry.sdk.trace.export import (
2225
BatchSpanProcessor,
@@ -30,9 +33,11 @@
3033
# opentelemetry-api can be present without the grpc otlp exporter package (e.g.
3134
# lite-bootstrap[fastmcp] pulls bare opentelemetry-api transitively); this must
3235
# stay a separate guard from is_opentelemetry_installed above.
36+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter as OTLPGrpcMetricExporter
3337
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as OTLPGrpcSpanExporter
3438

3539
if import_checker.is_otlp_http_exporter_installed:
40+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter as OTLPHttpMetricExporter
3641
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as OTLPHttpSpanExporter
3742

3843
if import_checker.is_pyroscope_installed:
@@ -64,6 +69,7 @@ class OpenTelemetryConfig(OpenTelemetryServiceFieldsConfig):
6469
default_factory=lambda: os.environ.get("HOSTNAME") or None
6570
)
6671
opentelemetry_endpoint: str | None = None
72+
opentelemetry_metrics_endpoint: str | None = None
6773
opentelemetry_insecure: bool = True
6874
opentelemetry_exporter_protocol: typing.Literal["grpc", "http"] = "grpc"
6975
opentelemetry_instrumentors: list[typing.Union[InstrumentorWithParams, "BaseInstrumentor"]] = dataclasses.field(
@@ -77,25 +83,30 @@ class OpenTelemetryConfig(OpenTelemetryServiceFieldsConfig):
7783
opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list)
7884

7985
def __post_init__(self) -> None:
80-
host = self._parse_remote_insecure_host()
81-
if host is not None:
86+
for host in self._parse_remote_insecure_hosts():
8287
warn_at_caller(
83-
f"OTLP exporter sending traces unencrypted to non-local host {host!r}; "
88+
f"OTLP exporter sending telemetry unencrypted to non-local host {host!r}; "
8489
"set opentelemetry_insecure=False or use a localhost/unix endpoint."
8590
)
8691
super().__post_init__()
8792

88-
def _parse_remote_insecure_host(self) -> str | None:
93+
def _parse_remote_insecure_hosts(self) -> list[str]:
94+
"""Return each distinct host an insecure, non-local endpoint points at, in declaration order."""
95+
endpoints = (self.opentelemetry_endpoint, self.opentelemetry_metrics_endpoint)
96+
hosts = (self._parse_remote_insecure_host(endpoint) for endpoint in endpoints)
97+
return list(dict.fromkeys(host for host in hosts if host is not None))
98+
99+
def _parse_remote_insecure_host(self, endpoint: str | None) -> str | None:
89100
"""Return the host name if the endpoint is insecure AND non-local; else None."""
90101
if self.opentelemetry_exporter_protocol != "grpc":
91102
return None
92-
if not self.opentelemetry_endpoint or not self.opentelemetry_insecure:
103+
if not endpoint or not self.opentelemetry_insecure:
93104
return None
94-
if self.opentelemetry_endpoint.startswith("unix://"):
105+
if endpoint.startswith("unix://"):
95106
return None
96107
# urlparse treats schemeless input as `path`, misparsing `host:port` forms.
97108
# Prepend `//` so urlparse always sees a network-location-style input.
98-
raw = self.opentelemetry_endpoint
109+
raw = endpoint
99110
if "://" not in raw:
100111
raw = f"//{raw}"
101112
parsed = urllib.parse.urlparse(raw)
@@ -144,18 +155,27 @@ class OpenTelemetryInstrument(BaseInstrument[OpenTelemetryConfig]):
144155
``OpenTelemetryInstrument`` per process; do not bootstrap a second instance.
145156
"""
146157

147-
not_configured_reason = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False"
158+
not_configured_reason = (
159+
"opentelemetry_endpoint and opentelemetry_metrics_endpoint are empty and opentelemetry_log_traces is False"
160+
)
148161
missing_dependency_message = "opentelemetry-sdk is not installed"
149162
_tracer_provider: "TracerProvider | None" = dataclasses.field(
150163
default_factory=lambda: None, init=False, repr=False, compare=False
151164
)
165+
_meter_provider: "MeterProvider | None" = dataclasses.field(
166+
default_factory=lambda: None, init=False, repr=False, compare=False
167+
)
152168
_prior_logger_disabled: dict[str, bool] = dataclasses.field(
153169
default_factory=dict, init=False, repr=False, compare=False
154170
)
155171

156172
@classmethod
157173
def is_configured(cls, bootstrap_config: "OpenTelemetryConfig") -> bool:
158-
return bool(bootstrap_config.opentelemetry_endpoint or bootstrap_config.opentelemetry_log_traces)
174+
return bool(
175+
bootstrap_config.opentelemetry_endpoint
176+
or bootstrap_config.opentelemetry_metrics_endpoint
177+
or bootstrap_config.opentelemetry_log_traces
178+
)
159179

160180
@staticmethod
161181
def dependencies_installed() -> bool:
@@ -223,6 +243,50 @@ def _build_span_exporter(self) -> "SpanExporter | None":
223243
return None
224244
return OTLPHttpSpanExporter(endpoint=config.opentelemetry_endpoint)
225245

246+
def _build_metric_exporter(self) -> "MetricExporter | None":
247+
"""Return the OTLP metric exporter for the configured protocol, or None after warning it is missing.
248+
249+
Only call this once opentelemetry_metrics_endpoint is set: both warnings claim that it is.
250+
"""
251+
config = self.bootstrap_config
252+
if config.opentelemetry_exporter_protocol == "grpc":
253+
if not import_checker.is_otlp_grpc_exporter_installed:
254+
warn_at_caller(
255+
"opentelemetry_metrics_endpoint is set but the gRPC OTLP exporter is not installed; "
256+
"metrics will not be exported. Install lite-bootstrap[otl].",
257+
category=InstrumentDependencyMissingWarning,
258+
)
259+
return None
260+
return OTLPGrpcMetricExporter(
261+
endpoint=config.opentelemetry_metrics_endpoint,
262+
insecure=config.opentelemetry_insecure,
263+
)
264+
if not import_checker.is_otlp_http_exporter_installed:
265+
warn_at_caller(
266+
"opentelemetry_metrics_endpoint is set but the HTTP OTLP exporter is not installed; "
267+
"metrics will not be exported. Install lite-bootstrap[otl-http].",
268+
category=InstrumentDependencyMissingWarning,
269+
)
270+
return None
271+
return OTLPHttpMetricExporter(endpoint=config.opentelemetry_metrics_endpoint)
272+
273+
def _bootstrap_metrics(self, resource: "resources.Resource") -> None:
274+
config = self.bootstrap_config
275+
if not config.opentelemetry_metrics_endpoint:
276+
return
277+
metric_exporter = self._build_metric_exporter()
278+
if metric_exporter is None:
279+
return
280+
meter_provider = MeterProvider(
281+
resource=resource, metric_readers=[PeriodicExportingMetricReader(metric_exporter)]
282+
)
283+
set_meter_provider(meter_provider)
284+
if get_meter_provider() is not meter_provider:
285+
warn_at_caller(
286+
"a MeterProvider is already installed; the configured metrics exporter and resource will not be used"
287+
)
288+
self._meter_provider = meter_provider
289+
226290
def _apply_instrumentors(self, tracer_provider: "TracerProvider") -> None:
227291
for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors:
228292
if isinstance(one_instrumentor, InstrumentorWithParams):
@@ -236,7 +300,8 @@ def _apply_instrumentors(self, tracer_provider: "TracerProvider") -> None:
236300
def bootstrap(self) -> None:
237301
config = self.bootstrap_config
238302
self._silence_otel_loggers()
239-
tracer_provider = TracerProvider(resource=self._build_resource(), sampler=config.opentelemetry_sampler)
303+
resource = self._build_resource()
304+
tracer_provider = TracerProvider(resource=resource, sampler=config.opentelemetry_sampler)
240305
set_tracer_provider(tracer_provider)
241306
if get_tracer_provider() is not tracer_provider:
242307
warn_at_caller(
@@ -249,6 +314,7 @@ def bootstrap(self) -> None:
249314
tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(formatter=_format_span)))
250315
if config.opentelemetry_endpoint and (span_exporter := self._build_span_exporter()):
251316
tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
317+
self._bootstrap_metrics(resource)
252318
self._apply_instrumentors(tracer_provider)
253319

254320
def teardown(self) -> None:
@@ -268,6 +334,12 @@ def teardown(self) -> None:
268334
self._tracer_provider.shutdown()
269335
finally:
270336
self._tracer_provider = None
337+
if self._meter_provider is not None:
338+
try:
339+
with teardown_errors.capture("MeterProvider"):
340+
self._meter_provider.shutdown()
341+
finally:
342+
self._meter_provider = None
271343

272344

273345
# Backward-compatible alias preserved for users importing the old (lowercase t) spelling.

‎scripts/floor_smoke.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def _free() -> None:
100100
sentry_tags={"floor": "smoke"},
101101
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
102102
opentelemetry_endpoint=OTLP_ENDPOINT,
103+
opentelemetry_metrics_endpoint=OTLP_ENDPOINT,
103104
opentelemetry_log_traces=True,
104105
)
105106
)
@@ -127,6 +128,7 @@ def _fastapi() -> None:
127128
sentry_additional_params=SENTRY_PARAMS,
128129
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
129130
opentelemetry_endpoint=OTLP_ENDPOINT,
131+
opentelemetry_metrics_endpoint=OTLP_ENDPOINT,
130132
opentelemetry_log_traces=True,
131133
)
132134
)
@@ -153,6 +155,7 @@ def _litestar() -> None:
153155
sentry_additional_params=SENTRY_PARAMS,
154156
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
155157
opentelemetry_endpoint=OTLP_ENDPOINT,
158+
opentelemetry_metrics_endpoint=OTLP_ENDPOINT,
156159
opentelemetry_log_traces=True,
157160
)
158161
)
@@ -174,6 +177,7 @@ def _faststream() -> None:
174177
sentry_additional_params=SENTRY_PARAMS,
175178
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
176179
opentelemetry_endpoint=OTLP_ENDPOINT,
180+
opentelemetry_metrics_endpoint=OTLP_ENDPOINT,
177181
opentelemetry_log_traces=True,
178182
)
179183
)

‎tests/conftest.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import warnings
55
from importlib import reload
66

7+
import opentelemetry.metrics._internal
78
import opentelemetry.trace
89
import pytest
910
import sentry_sdk
@@ -44,16 +45,19 @@ def capture_envelope(self, envelope: Envelope) -> None:
4445

4546

4647
@pytest.fixture(autouse=True)
47-
def _reset_tracer_provider() -> typing.Iterator[None]:
48-
"""Give each test the process-global TracerProvider unset, as a fresh process has it.
48+
def _reset_otel_providers() -> typing.Iterator[None]:
49+
"""Give each test the process-global tracer and meter providers unset, as a fresh process has them.
4950
50-
`set_tracer_provider` is set-once, so without this the first test to bootstrap owns the global
51-
and every later one silently loses the race against it. Restoring the two module-level names
52-
the SDK guards it with is the only way back: there is no public reset.
51+
Both setters are set-once, so without this the first test to bootstrap owns the global and every
52+
later one silently loses the race against it. Restoring the module-level names the SDK guards
53+
them with is the only way back: there is no public reset. The meter provider keeps its pair in
54+
`opentelemetry.metrics._internal`, one module below the public re-export.
5355
"""
5456
yield
5557
opentelemetry.trace._TRACER_PROVIDER = None # noqa: SLF001
5658
opentelemetry.trace._TRACER_PROVIDER_SET_ONCE = Once() # noqa: SLF001
59+
opentelemetry.metrics._internal._METER_PROVIDER = None # noqa: SLF001
60+
opentelemetry.metrics._internal._METER_PROVIDER_SET_ONCE = Once() # noqa: SLF001
5761

5862

5963
@pytest.fixture

0 commit comments

Comments
 (0)