Skip to content

Commit 57597f9

Browse files
authored
Record HTTP metrics once when both OpenTelemetry metrics and Prometheus are configured (#365)
1 parent fc71cb7 commit 57597f9

15 files changed

Lines changed: 336 additions & 30 deletions

‎benchmarks/README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,28 @@ OpenTelemetry rows record the `receive` and `send` spans, which 2.0 leaves out b
251251
4a). Its `sentry_nosess` row now has the same config as `sentry`, and `sentry_noauto` no longer stops request
252252
capture, because lean capture needs no auto-enabling integration.
253253

254+
Since 2.0, the Prometheus instrument leaves its HTTP middleware out while OpenTelemetry records HTTP
255+
metrics. Rerun in-process on the M2 host (macOS, CPython 3.14.7, opentelemetry-sdk 1.45.1,
256+
instrumentation-asgi and -fastapi 0.66b1, prometheus-client 0.26.0,
257+
prometheus-fastapi-instrumentator 8.1.0, fastapi 0.162.0, litestar 2.25.0), with
258+
`bench.py <suite> bare,otel_0,otel_0_metrics,otel_0_prom,otel_0_metrics_prom_both,otel_0_metrics_prom --repeat 3`,
259+
in µs over each suite's bare app:
260+
261+
| scenario | FastAPI | Litestar |
262+
|---|---:|---:|
263+
| `otel_0`: OTel at 0% | +47.4 | +38.8 |
264+
| `otel_0_metrics`: + OTel HTTP metrics | +83.3 | +72.8 |
265+
| `otel_0_prom`: + Prometheus | +71.8 | +56.4 |
266+
| `otel_0_metrics_prom_both`: both, `prometheus_http_metrics_enabled=True` (1.x) | +106.4 | +90.2 |
267+
| `otel_0_metrics_prom`: both configured, the 2.0 default | +84.5 | +73.4 |
268+
269+
Every row's rounds were within 2%. Configuring both now costs what OTel HTTP metrics cost alone,
270+
21.9 µs less on FastAPI and 16.8 µs less on Litestar. On this host the OTel HTTP metrics cost 34 to
271+
36 µs and Prometheus's 18 to 25 µs, the reverse of the Linux table above, where OTel's were the
272+
cheaper set at about 12 µs. The stack suite (`run.py stack async otel,otel_metrics,otel_metrics_prom_both,otel_metrics_prom`,
273+
100% sampling) agrees: +44.4 µs for OTel HTTP metrics, +71.8 for both sets and +48.1 for the 2.0
274+
default, over `otel` at 109.0 µs per request.
275+
254276
FastAPI real-server runs under uvicorn in the container were unstable (bare repeat medians of 8235
255277
and 6341 RPS, over 100% spread on the full stack), so the Linux real-server numbers below are
256278
Litestar on Granian only.
@@ -657,6 +679,9 @@ cd benchmarks
657679
# the two OpenTelemetry knobs (section 4a)
658680
../.venv/bin/python run.py stack async otel,otel_exclude_spans,otel_sampler,otel_tuned
659681

682+
# HTTP metrics recorded once against both sets (section 4d)
683+
../.venv/bin/python run.py stack async otel,otel_metrics,otel_metrics_prom_both,otel_metrics_prom
684+
660685
# the Sentry ablation, where every familiar knob turns out to be a no-op (section 4b)
661686
../.venv/bin/python run.py sentry async off,errors_only,errors_only_lean,errors_only_no_integrations,errors_only_no_txn
662687

‎benchmarks/frameworks/fastapi_suite.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@
4646
"otel_0_metrics": Scenario(config=base_config(**OTEL, **OTEL_METRICS, **ratio(0.0))),
4747
"otel_0_prom": Scenario(config=base_config(**OTEL, **ratio(0.0), **PROM)),
4848
"otel_0_metrics_prom": Scenario(config=base_config(**OTEL, **OTEL_METRICS, **ratio(0.0), **PROM)),
49+
"otel_0_metrics_prom_both": Scenario(
50+
config=base_config(**OTEL, **OTEL_METRICS, **ratio(0.0), **PROM, prometheus_http_metrics_enabled=True)
51+
),
4952
"sentry": Scenario(config=base_config(**sentry())),
5053
"sentry_nosess": Scenario(config=base_config(**sentry(), sentry_auto_session_tracking=False)),
5154
"sentry_noauto": Scenario(config=base_config(**sentry(auto_enabling_integrations=False))),

‎benchmarks/frameworks/litestar_suite.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
"otel_0_metrics": Scenario(config=base_config(**OTEL, **OTEL_METRICS, **ratio(0.0))),
6262
"otel_0_prom": Scenario(config=base_config(**OTEL, **ratio(0.0), **PROM)),
6363
"otel_0_metrics_prom": Scenario(config=base_config(**OTEL, **OTEL_METRICS, **ratio(0.0), **PROM)),
64+
"otel_0_metrics_prom_both": Scenario(
65+
config=base_config(**OTEL, **OTEL_METRICS, **ratio(0.0), **PROM, prometheus_http_metrics_enabled=True)
66+
),
6467
"sentry": Scenario(config=base_config(**sentry())),
6568
"sentry_nosess": Scenario(config=base_config(**sentry(), sentry_auto_session_tracking=False)),
6669
"sentry_noauto": Scenario(config=base_config(**sentry(auto_enabling_integrations=False))),

‎benchmarks/stack_scenarios.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
OTLP_PORT: typing.Final = int(os.environ.get("STUB_OTLP_PORT", "8139"))
2525
OTLP_ENDPOINT: typing.Final = f"http://127.0.0.1:{OTLP_PORT}/v1/traces"
26+
OTLP_METRICS_ENDPOINT: typing.Final = f"http://127.0.0.1:{OTLP_PORT}/v1/metrics"
2627
SAMPLE_RATIO: typing.Final = 0.01
2728

2829
APPS: typing.Final = ("async", "structlog")
@@ -41,7 +42,9 @@
4142
}
4243
_OTEL_EXCLUDE_SPANS: typing.Final = {"opentelemetry_exclude_spans": ["receive", "send"]}
4344
_OTEL_SAMPLER: typing.Final = {"opentelemetry_sampler": ParentBased(TraceIdRatioBased(SAMPLE_RATIO))}
45+
_OTEL_METRICS: typing.Final = {"opentelemetry_metrics_endpoint": OTLP_METRICS_ENDPOINT}
4446
_PROMETHEUS: typing.Final = {"prometheus_metrics_path": "/metrics"}
47+
_PROMETHEUS_HTTP_METRICS: typing.Final = {"prometheus_http_metrics_enabled": True}
4548
_LOGGING: typing.Final = {"logging_enabled": True}
4649
_SENTRY: typing.Final = {
4750
"sentry_dsn": DSN,
@@ -86,6 +89,9 @@ def config(*parts: dict[str, typing.Any]) -> dict[str, typing.Any]:
8689
"full_no_sentry": lambda: config(_OTEL, _PROMETHEUS, _LOGGING),
8790
"full_sentry_tuned": lambda: config(_OTEL, _PROMETHEUS, _LOGGING, _sentry_tuned()),
8891
"otel_exclude_spans": lambda: config(_OTEL, _OTEL_EXCLUDE_SPANS),
92+
"otel_metrics": lambda: config(_OTEL, _OTEL_METRICS),
93+
"otel_metrics_prom": lambda: config(_OTEL, _OTEL_METRICS, _PROMETHEUS),
94+
"otel_metrics_prom_both": lambda: config(_OTEL, _OTEL_METRICS, _PROMETHEUS, _PROMETHEUS_HTTP_METRICS),
8995
"otel_sampler": lambda: config(_OTEL, _OTEL_SAMPLER),
9096
"otel_tuned": lambda: config(_OTEL, _OTEL_EXCLUDE_SPANS, _OTEL_SAMPLER),
9197
"full_all_tuned": lambda: config(_OTEL, _OTEL_EXCLUDE_SPANS, _OTEL_SAMPLER, _PROMETHEUS, _LOGGING, _sentry_tuned()),

‎docs/introduction/configuration.md‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,22 @@ Prometheus is on by default when its extra is installed, serving metrics at `pro
116116
Additional parameters:
117117

118118
- `prometheus_metrics_include_in_schema`.
119+
- `prometheus_http_metrics_enabled` - FastAPI, Litestar and FastMCP only; see below.
120+
121+
### Prometheus HTTP metrics
122+
123+
On FastAPI, Litestar and FastMCP, the Prometheus instrument records every HTTP request, and so does
124+
OpenTelemetry's HTTP instrumentation once `opentelemetry_metrics_endpoint` installs a
125+
`MeterProvider`. Since 2.0 only one of them does. With `prometheus_http_metrics_enabled` left at
126+
`None`, the Prometheus instrument leaves its HTTP middleware out whenever OpenTelemetry records HTTP
127+
metrics: the endpoint is set, and the OpenTelemetry packages and the OTLP metric exporter for
128+
`opentelemetry_exporter_protocol` are installed. The metrics route is still served, for your own
129+
collectors and, on FastMCP, the tool-call metrics. Configuring both then costs 17 to 22 µs less per
130+
request than it did in 1.x.
131+
132+
- `True` keeps the Prometheus HTTP metrics as well, for a service moving dashboards from one
133+
backend to the other.
134+
- `False` leaves them out even without OpenTelemetry metrics.
119135

120136
### Prometheus Litestar
121137

@@ -163,7 +179,8 @@ packages, and serves `prometheus_client.REGISTRY` at `prometheus_metrics_path`.
163179
Every ASGI application `application.http_app()` builds counts its requests in `http_requests_total`,
164180
`http_request_duration_seconds` and the instrumentator's other default metrics, labelled by route
165181
(`handler="/mcp"`); requests to unknown paths are grouped into `handler="none"`, and the metrics
166-
route itself is not counted. Tool calls are counted in `fastmcp_tool_calls_total{tool, status}`, with
182+
route itself is not counted. Those HTTP metrics are left out while OpenTelemetry records its own
183+
([Prometheus HTTP metrics](#prometheus-http-metrics)). Tool calls are counted in `fastmcp_tool_calls_total{tool, status}`, with
167184
`status` either `success` or `error`, and timed in `fastmcp_tool_call_duration_seconds{tool}`.
168185

169186
Additional parameters for FastMCP integration:
@@ -206,10 +223,11 @@ nowhere.
206223

207224
The export interval is the SDK's, 60 seconds, overridable with `OTEL_METRIC_EXPORT_INTERVAL`.
208225

209-
If the Prometheus instrument is also configured, request metrics are recorded twice: once for the
210-
scrape endpoint and once for the OTLP pipeline, under different names. That is supported, and it
211-
is what you want when the two feed different backends, but pointing both at one backend
212-
double-counts.
226+
On FastAPI, Litestar and FastMCP, HTTP request metrics are recorded once. Since 2.0, when this
227+
endpoint is set, the Prometheus instrument stops adding its HTTP middleware and OpenTelemetry's set
228+
is the only one; see [Prometheus HTTP metrics](#prometheus-http-metrics). FastStream is the
229+
exception: both of its broker middlewares are opt-in, so with both configured each message is
230+
counted by both.
213231

214232
```python
215233
config = FastAPIConfig(

‎docs/introduction/performance.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ request, every request then pays the per-record cost measured here:
6868

6969
--8<-- "benchmarks/README.md:logging"
7070

71-
lite-bootstrap already applies six savings for you. It turns Sentry Logs off in the logging
71+
lite-bootstrap already applies seven savings for you. It turns Sentry Logs off in the logging
7272
integration, because it never enables them and on 2.x the handler formats every record before
7373
checking whether they are enabled; that one costs nothing. Since 2.0 it also leaves release-health
7474
sessions off and starts breadcrumbs at `WARNING`, so an `INFO` record skips the breadcrumb handler.
@@ -80,7 +80,9 @@ sentry-sdk's [auto-enabling integrations](configuration.md#sentry-auto-enabling-
8080
which costs you their SQL, Redis and outgoing-HTTP breadcrumbs; set
8181
`sentry_auto_enabling_integrations=True` to keep them. On FastAPI, Litestar and FastMCP it also
8282
records no span for the ASGI `receive` and `send` events, which costs you those spans; set
83-
`opentelemetry_exclude_spans=[]` to get them back. The numbers on this page were measured before
83+
`opentelemetry_exclude_spans=[]` to get them back. With `opentelemetry_metrics_endpoint` set it
84+
records HTTP metrics only through OpenTelemetry, which costs you the Prometheus HTTP metrics; set
85+
`prometheus_http_metrics_enabled=True` to keep both. The numbers on this page were measured before
8486
2.0, against the old defaults.
8587

8688
## What each saving costs you

‎lite_bootstrap/bootstrappers/fastapi_bootstrapper.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
)
1818
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument
1919
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryAsgiConfig, OpenTelemetryInstrument
20-
from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument
20+
from lite_bootstrap.instruments.prometheus_instrument import PrometheusHttpConfig, PrometheusInstrument
2121
from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument
2222
from lite_bootstrap.instruments.sentry_instrument import (
2323
SentryInstrument,
@@ -65,7 +65,7 @@ class FastAPIConfig(
6565
HealthChecksConfig,
6666
LoggingConfig,
6767
OpenTelemetryAsgiConfig,
68-
PrometheusConfig,
68+
PrometheusHttpConfig,
6969
PyroscopeConfig,
7070
SentryLeanCaptureConfig,
7171
SwaggerConfig,
@@ -248,10 +248,12 @@ def dependencies_installed() -> bool:
248248
def bootstrap(self) -> None:
249249
config = self.bootstrap_config
250250
application = config.app
251-
Instrumentator(**config.prometheus_instrumentator_additional_params).instrument(
252-
application,
253-
**config.prometheus_instrument_additional_params,
254-
).expose(
251+
instrumentator = Instrumentator(**config.prometheus_instrumentator_additional_params)
252+
if config.is_prometheus_http_metrics_on(
253+
opentelemetry_records_http_metrics=FastAPIOpenTelemetryInstrument.is_recording_metrics(config)
254+
):
255+
instrumentator.instrument(application, **config.prometheus_instrument_additional_params)
256+
instrumentator.expose(
255257
application,
256258
endpoint=config.prometheus_metrics_path,
257259
include_in_schema=config.prometheus_metrics_include_in_schema,

‎lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument
1313
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument
1414
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryAsgiConfig, OpenTelemetryInstrument
15-
from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument
15+
from lite_bootstrap.instruments.prometheus_instrument import PrometheusHttpConfig, PrometheusInstrument
1616
from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument
1717
from lite_bootstrap.instruments.sentry_instrument import (
1818
SentryInstrument,
@@ -204,7 +204,7 @@ class FastMcpConfig(
204204
HealthChecksConfig,
205205
LoggingConfig,
206206
OpenTelemetryAsgiConfig,
207-
PrometheusConfig,
207+
PrometheusHttpConfig,
208208
PyroscopeConfig,
209209
SentryLeanCaptureConfig,
210210
):
@@ -328,7 +328,10 @@ def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "St
328328

329329
def bootstrap(self) -> None:
330330
config = self.bootstrap_config
331-
self._restore_http_app = _postprocess_http_apps(config.application, self._instrument_http_app)
331+
if config.is_prometheus_http_metrics_on(
332+
opentelemetry_records_http_metrics=FastMcpOpenTelemetryInstrument.is_recording_metrics(config)
333+
):
334+
self._restore_http_app = _postprocess_http_apps(config.application, self._instrument_http_app)
332335
if config.fastmcp_prometheus_tool_metrics_enabled:
333336
config.application.add_middleware(FastMcpPrometheusMiddleware(*_build_tool_call_metrics()))
334337

‎lite_bootstrap/bootstrappers/litestar_bootstrapper.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,7 @@
1616
)
1717
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument
1818
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryAsgiConfig, OpenTelemetryInstrument
19-
from lite_bootstrap.instruments.prometheus_instrument import (
20-
PrometheusConfig as PrometheusBootstrapperConfig,
21-
)
22-
from lite_bootstrap.instruments.prometheus_instrument import (
23-
PrometheusInstrument,
24-
)
19+
from lite_bootstrap.instruments.prometheus_instrument import PrometheusHttpConfig, PrometheusInstrument
2520
from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument
2621
from lite_bootstrap.instruments.sentry_instrument import (
2722
SENTRY_FAILED_REQUEST_STATUS_CODES,
@@ -255,7 +250,7 @@ class LitestarConfig(
255250
HealthChecksConfig,
256251
LoggingConfig,
257252
OpenTelemetryAsgiConfig,
258-
PrometheusBootstrapperConfig,
253+
PrometheusHttpConfig,
259254
PyroscopeConfig,
260255
SentryLeanCaptureConfig,
261256
SwaggerConfig,
@@ -432,7 +427,10 @@ class LitestarPrometheusController(PrometheusController):
432427
litestar_prometheus_config = PrometheusConfig(app_name=config.service_name, **prometheus_params)
433428

434429
config.application_config.route_handlers.append(LitestarPrometheusController)
435-
config.application_config.middleware.append(litestar_prometheus_config.middleware)
430+
if config.is_prometheus_http_metrics_on(
431+
opentelemetry_records_http_metrics=LitestarOpenTelemetryInstrument.is_recording_metrics(config)
432+
):
433+
config.application_config.middleware.append(litestar_prometheus_config.middleware)
436434

437435

438436
@dataclasses.dataclass(kw_only=True, slots=True)

‎lite_bootstrap/instruments/opentelemetry_instrument.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,15 @@ def is_configured(cls, bootstrap_config: "OpenTelemetryConfig") -> bool:
194194
or bootstrap_config.opentelemetry_log_traces
195195
)
196196

197+
@classmethod
198+
def is_recording_metrics(cls, bootstrap_config: "OpenTelemetryConfig") -> bool:
199+
"""Whether bootstrap() installs a MeterProvider, which the framework's HTTP instrumentation records into."""
200+
if not bootstrap_config.opentelemetry_metrics_endpoint or not cls.dependencies_installed():
201+
return False
202+
if bootstrap_config.opentelemetry_exporter_protocol == "grpc":
203+
return import_checker.is_otlp_grpc_exporter_installed
204+
return import_checker.is_otlp_http_exporter_installed
205+
197206
@staticmethod
198207
def dependencies_installed() -> bool:
199208
# The instrument imports from both the api (opentelemetry.trace/.context) and

0 commit comments

Comments
 (0)