Skip to content

Commit b0e6fba

Browse files
authored
Default opentelemetry_generate_health_check_spans to False (#366)
1 parent 57597f9 commit b0e6fba

10 files changed

Lines changed: 88 additions & 14 deletions

File tree

‎benchmarks/README.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ structlog 26.1.0. In-process, baseline 17.7 µs per request. The handler does no
228228
| Sentry, `sentry_auto_session_tracking=False` | +51.8 | |
229229
| Sentry, `auto_enabling_integrations=False` | +0.1 | the Starlette and FastAPI integrations are auto-enabling, so nothing captures request errors |
230230
| health check `/health/` | +22.6 | noisy, 16% spread |
231-
| health check with OpenTelemetry, spans on (default) / off | +162.3 / +28.9 | noisy, 32 to 50% spread |
231+
| health check with OpenTelemetry, spans on (1.x default) / off | +162.3 / +28.9 | noisy, 32 to 50% spread |
232232
| CORS, request with a matching Origin | +3.8 | |
233233
| full: OTel + Prometheus + logging + Sentry + health, defaults | +259.6 | |
234234
<!-- --8<-- [end:fastapilinux] -->
@@ -273,6 +273,18 @@ cheaper set at about 12 µs. The stack suite (`run.py stack async otel,otel_metr
273273
100% sampling) agrees: +44.4 µs for OTel HTTP metrics, +71.8 for both sets and +48.1 for the 2.0
274274
default, over `otel` at 109.0 µs per request.
275275

276+
Since 2.0, health checks are untraced by default (`opentelemetry_generate_health_check_spans=False`).
277+
Rerun on the same host with `bench.py <suite> health,health_otel,health_otel_spans --repeat 3`, at 100%
278+
sampling, in µs per `/health/` request over the health check without OpenTelemetry:
279+
280+
| scenario | FastAPI | Litestar |
281+
|---|---:|---:|
282+
| `health_otel`: the 2.0 default, no span | +5.1 | +1.9 |
283+
| `health_otel_spans`: `opentelemetry_generate_health_check_spans=True`, the 1.x default | +99.2 | +82.4 |
284+
285+
Litestar's rounds differed by up to 24%. At one probe every few seconds per pod this is far below
286+
a percent of a core either way. What the default saves is one trace per probe per pod.
287+
276288
FastAPI real-server runs under uvicorn in the container were unstable (bare repeat medians of 8235
277289
and 6341 RPS, over 100% spread on the full stack), so the Linux real-server numbers below are
278290
Litestar on Granian only.
@@ -431,7 +443,7 @@ prometheus-client 0.26.0, structlog 26.1.0. In-process, baseline 12.1 µs per re
431443
| CORS, simple request with a matching Origin | +7.2 | against bare with the same header |
432444
| Swagger (`/docs`), online or offline | -0.2 | registers routes only |
433445
| health check `/health/`, against an equivalent `/ping` handler | +7.1 | `asyncio.gather` of one coroutine |
434-
| health check with OpenTelemetry, spans on (default) | +134.0 | against health without OpenTelemetry |
446+
| health check with OpenTelemetry, spans on (1.x default) | +134.0 | against health without OpenTelemetry |
435447
| health check with OpenTelemetry, `opentelemetry_generate_health_check_spans=False` | +9.2 | |
436448
| full: OTel + Prometheus + logging + Sentry + health, defaults | +297.7 | |
437449
| full, 3 structlog records per request | +377.8 | |
@@ -741,6 +753,7 @@ cd benchmarks/frameworks
741753
../../.venv/bin/python bench.py fastapi bare,log_mw,prom,otel,otel_excl,otel_0,sentry,health,full --repeat 2
742754
../../.venv/bin/python bench.py litestar bare,log_log3,log_mw,prom,otel,otel_excl,otel_1_excl,otel_0,sentry,full --repeat 2
743755
../../.venv/bin/python bench.py litestar health_ping,health,health_otel,health_otel_nospan --repeat 2
756+
../../.venv/bin/python bench.py fastapi health,health_otel,health_otel_spans --repeat 3
744757

745758
# Litestar on Granian, over real sockets (section 8c); needs granian in the environment and `ab` on PATH
746759
../../.venv/bin/python run_http.py litestar bare,otel,otel_excl,prom,log_mw,sentry,health,full --repeat 2

‎benchmarks/frameworks/fastapi_suite.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@
6464
),
6565
"health": Scenario(config=base_config(**HEALTH), path="/health/"),
6666
"health_otel": Scenario(config=base_config(**HEALTH, **OTEL), path="/health/"),
67+
"health_otel_spans": Scenario(
68+
config=base_config(**HEALTH, **OTEL, opentelemetry_generate_health_check_spans=True), path="/health/"
69+
),
6770
"health_otel_nospan": Scenario(
6871
config=base_config(**HEALTH, **OTEL, opentelemetry_generate_health_check_spans=False), path="/health/"
6972
),

‎benchmarks/frameworks/litestar_suite.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@
8484
"health_ping": Scenario(config=base_config(**HEALTH), kind="healthlike"),
8585
"health": Scenario(config=base_config(**HEALTH), path="/health/"),
8686
"health_otel": Scenario(config=base_config(**HEALTH, **OTEL), path="/health/"),
87+
"health_otel_spans": Scenario(
88+
config=base_config(**HEALTH, **OTEL, opentelemetry_generate_health_check_spans=True), path="/health/"
89+
),
8790
"health_otel_nospan": Scenario(
8891
config=base_config(**HEALTH, **OTEL, opentelemetry_generate_health_check_spans=False), path="/health/"
8992
),

‎docs/integrations/fastmcp.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ wrapped in OpenTelemetry's ASGI middleware. Each request produces a server span
104104
route, such as `POST /mcp` or `GET /health/`, with the route in `http.route`. A request to an
105105
unknown path produces a span named after the HTTP method alone, without `http.route`.
106106

107-
The metrics path, the health-check path (unless `opentelemetry_generate_health_check_spans` is on)
107+
The metrics path, the health-check path (unless `opentelemetry_generate_health_check_spans` is True)
108108
`opentelemetry_excluded_urls` and `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` produce no spans. An application already instrumented by
109109
`StarletteInstrumentor` is left as is, so no request is traced twice.
110110

‎docs/introduction/configuration.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,11 @@ Additional parameters:
210210
- `opentelemetry_instrumentors` - a list of extra instrumentors.
211211
- `opentelemetry_log_traces` - traces will be logged to stdout.
212212
- `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.
213-
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`.
213+
- `opentelemetry_generate_health_check_spans` - trace health-check requests. Defaults to `False`
214+
since 2.0, so a probe sends no trace, the same as the access log, which never records the
215+
health-check path. While it is `False`, OpenTelemetry's HTTP metrics also leave health-check
216+
requests out, because the instrumentation skips an excluded URL entirely. Set it to `True` to
217+
trace probes, for example to watch their latency.
214218
- `opentelemetry_excluded_urls` - extra regexes for URLs excluded from tracing; the metrics path and (unless health-check spans are enabled) the health-check path are excluded automatically. Litestar searches them in the request path, FastAPI and FastMCP in the full URL. The environment variable OpenTelemetry defines for the framework (`OTEL_PYTHON_LITESTAR_EXCLUDED_URLS`, `OTEL_PYTHON_FASTAPI_EXCLUDED_URLS` or `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` for FastMCP), falling back to `OTEL_PYTHON_EXCLUDED_URLS`, adds to them.
215219
- `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.
216220

@@ -265,7 +269,7 @@ For FastStream there is additionally:
265269
`faststream.redis.opentelemetry.RedisTelemetryMiddleware`, or a mapping from broker class to
266270
middleware class (see [multiple brokers](../integrations/faststream.md#multiple-brokers)). You must
267271
provide it to trace broker messages. Without it the rest of the configuration above still applies: the exporter is built, the
268-
health-check span is recorded unless `opentelemetry_generate_health_check_spans` is False, and any
272+
health-check span is recorded when `opentelemetry_generate_health_check_spans` is True, and any
269273
`opentelemetry_instrumentors` are applied.
270274

271275
### Resource

‎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 seven savings for you. It turns Sentry Logs off in the logging
71+
lite-bootstrap already applies eight 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.
@@ -82,7 +82,9 @@ which costs you their SQL, Redis and outgoing-HTTP breadcrumbs; set
8282
records no span for the ASGI `receive` and `send` events, which costs you those spans; set
8383
`opentelemetry_exclude_spans=[]` to get them back. With `opentelemetry_metrics_endpoint` set it
8484
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
85+
`prometheus_http_metrics_enabled=True` to keep both. It also leaves health checks untraced, which
86+
costs you probe traces; set `opentelemetry_generate_health_check_spans=True` to get them back. The
87+
numbers on this page were measured before
8688
2.0, against the old defaults.
8789

8890
## What each saving costs you

‎lite_bootstrap/instruments/opentelemetry_instrument.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ class OpenTelemetryConfig(OpenTelemetryServiceFieldsConfig):
8383
# Left None, the SDK picks its own default, which reads OTEL_TRACES_SAMPLER; see ADR-0008
8484
# (docs/adr/0008-otel-sampling-is-a-sampler-object.md).
8585
opentelemetry_sampler: "Sampler | None" = None
86-
opentelemetry_generate_health_check_spans: bool = True
86+
opentelemetry_generate_health_check_spans: bool = False
8787
opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list)
8888

8989
def __post_init__(self) -> None:

‎tests/test_fastapi_bootstrap.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,17 @@ def test_fastapi_otel_excludes_derived_paths_only_where_they_match_whole_segment
602602
assert sorted(span_names) == ["GET /api/custom-metrics/report", "GET /custom-healthy"]
603603

604604

605+
def test_fastapi_otel_leaves_the_health_check_untraced_by_default() -> None:
606+
config = FastAPIConfig(
607+
service_name="microservice",
608+
health_checks_path="/custom-health/",
609+
logging_buffer_capacity=0,
610+
opentelemetry_log_traces=True,
611+
)
612+
613+
assert _server_span_names_for(config, ["/custom-health/", "/custom-healthy"]) == ["GET /custom-healthy"]
614+
615+
605616
@pytest.mark.parametrize("environment_variable", ["OTEL_PYTHON_FASTAPI_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"])
606617
def test_fastapi_otel_honors_excluded_urls_from_environment(
607618
fastapi_config: FastAPIConfig, monkeypatch: pytest.MonkeyPatch, environment_variable: str

‎tests/test_fastmcp_bootstrap.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,11 @@ def _server_spans(exporter: InMemorySpanExporter) -> list[ReadableSpan]:
398398
def _bootstrapped_with_span_exporter(
399399
**overrides: typing.Any, # noqa: ANN401
400400
) -> Generator[tuple[FastMcpBootstrapper, FastMCP, InMemorySpanExporter]]:
401-
bootstrapper = FastMcpBootstrapper(bootstrap_config=_make_test_config(opentelemetry_log_traces=True, **overrides))
401+
"""Trace with health-check spans on, since the health route is the cheapest request these tests send."""
402+
config_overrides: typing.Final = {"opentelemetry_generate_health_check_spans": True, **overrides}
403+
bootstrapper = FastMcpBootstrapper(
404+
bootstrap_config=_make_test_config(opentelemetry_log_traces=True, **config_overrides)
405+
)
402406
application = bootstrapper.bootstrap()
403407
tracer_provider = get_tracer_provider()
404408
assert isinstance(tracer_provider, SDKTracerProvider)
@@ -522,8 +526,8 @@ def _unregister_request_metrics() -> None:
522526

523527

524528
def _http_metric_sets_one_request_moves(**overrides: typing.Any) -> tuple[bool, bool]: # noqa: ANN401
525-
"""Return whether one request reached OpenTelemetry's HTTP metrics and Prometheus's, in that order."""
526-
config: typing.Final = _make_test_config(**overrides)
529+
"""Return whether one health-check request reached OpenTelemetry's HTTP metrics and Prometheus's, in that order."""
530+
config: typing.Final = _make_test_config(**{"opentelemetry_generate_health_check_spans": True, **overrides})
527531
with in_memory_opentelemetry_metric_reader() as reader:
528532
bootstrapper = FastMcpBootstrapper(bootstrap_config=config)
529533
application = bootstrapper.bootstrap()

‎tests/test_faststream_bootstrap.py‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@
2020
from opentelemetry.metrics import MeterProvider, get_meter_provider
2121
from opentelemetry.sdk import resources
2222
from opentelemetry.sdk.metrics import MeterProvider as SDKMeterProvider
23-
from opentelemetry.trace import TracerProvider
23+
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
24+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
25+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
26+
from opentelemetry.trace import TracerProvider, get_tracer, get_tracer_provider
2427
from starlette import status
2528
from starlette.testclient import TestClient
2629

2730
from lite_bootstrap import FastStreamBootstrapper, FastStreamConfig
31+
from lite_bootstrap.bootstrappers import faststream_bootstrapper
2832
from lite_bootstrap.bootstrappers.faststream_bootstrapper import (
2933
FastStreamLoggingInstrument,
3034
FastStreamOpenTelemetryInstrument,
@@ -213,11 +217,39 @@ def test_faststream_bootstrap_without_prometheus_client(broker: RedisBroker) ->
213217
bootstrapper.bootstrap()
214218

215219

220+
def _span_names_for_one_health_check(config: FastStreamConfig) -> list[str]:
221+
bootstrapper = FastStreamBootstrapper(bootstrap_config=config)
222+
# The module's tracer stays bound to the first provider a test installed; this one resolves to this test's.
223+
with patch.object(faststream_bootstrapper, "tracer", get_tracer(__name__)):
224+
application = bootstrapper.bootstrap()
225+
tracer_provider = get_tracer_provider()
226+
assert isinstance(tracer_provider, SDKTracerProvider)
227+
exporter = InMemorySpanExporter()
228+
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
229+
try:
230+
TestClient(app=application).get(config.health_checks_path)
231+
finally:
232+
bootstrapper.teardown()
233+
return [span.name for span in exporter.get_finished_spans()]
234+
235+
236+
def test_faststream_health_check_records_no_span_by_default() -> None:
237+
assert _span_names_for_one_health_check(build_faststream_config()) == []
238+
239+
240+
def test_faststream_health_check_span_is_recorded_when_turned_on() -> None:
241+
config = dataclasses.replace(build_faststream_config(), opentelemetry_generate_health_check_spans=True)
242+
243+
assert _span_names_for_one_health_check(config) == [f"GET {config.health_checks_path}"]
244+
245+
216246
def test_faststream_bootstrap_without_opentelemetry(broker: RedisBroker) -> None:
217247
# Regression: issue #87 bug 2 — FastStreamHealthChecksInstrument.bootstrap
218248
# referenced unbound `tracer` when opentelemetry was absent and
219249
# opentelemetry_generate_health_check_spans defaulted to True.
220-
bootstrap_config = build_faststream_config(broker=broker)
250+
bootstrap_config = dataclasses.replace(
251+
build_faststream_config(broker=broker), opentelemetry_generate_health_check_spans=True
252+
)
221253
with emulate_package_missing_with_module_reload(
222254
"opentelemetry",
223255
["lite_bootstrap.bootstrappers.faststream_bootstrapper"],
@@ -326,7 +358,9 @@ def test_faststream_build_excluded_urls_covers_prometheus_and_health_paths(broke
326358
assert config_no_health_spans.prometheus_metrics_path in excluded # always excluded
327359
assert config_no_health_spans.health_checks_path in excluded # excluded when health spans are off
328360

329-
config_with_health_spans = build_faststream_config(broker=broker) # generate_health_check_spans defaults True
361+
config_with_health_spans = dataclasses.replace(
362+
build_faststream_config(broker=broker), opentelemetry_generate_health_check_spans=True
363+
)
330364
excluded_with = FastStreamOpenTelemetryInstrument(bootstrap_config=config_with_health_spans)._build_excluded_urls() # noqa: SLF001
331365
assert config_with_health_spans.prometheus_metrics_path in excluded_with
332366
assert config_with_health_spans.health_checks_path not in excluded_with # kept when health spans are on

0 commit comments

Comments
 (0)