From 45651fd9368c223f82c83a08351059e903b5f494 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 29 Oct 2026 17:58:15 +0300 Subject: [PATCH] Honour OTEL_SERVICE_NAME and send service_version as the Sentry release --- docs/introduction/configuration.md | 14 +++++++-- .../bootstrappers/fastapi_bootstrapper.py | 4 +-- .../bootstrappers/litestar_bootstrapper.py | 6 ++-- lite_bootstrap/instruments/base.py | 16 ++++++++-- .../instruments/healthchecks_instrument.py | 4 +-- .../instruments/pyroscope_instrument.py | 2 +- .../instruments/sentry_instrument.py | 2 ++ .../test_opentelemetry_instrument.py | 22 +++++++++++-- .../instruments/test_pyroscope_instrument.py | 7 +++++ tests/instruments/test_sentry_instrument.py | 31 +++++++++++++++++++ tests/test_config.py | 28 ++++++++++++----- tests/test_fastapi_bootstrap.py | 6 ++++ tests/test_litestar_bootstrap.py | 10 ++++++ 13 files changed, 131 insertions(+), 21 deletions(-) diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 12493bd..9da8d2f 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -1,5 +1,13 @@ # Configuration +## Service name and version + +`service_name` and `service_version` default to `None` since 2.0. Left unset: + +- OpenTelemetry leaves `service.name` and `service.version` out of the resource, so the SDK takes them from `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES`. With neither set, `service.name` is `unknown_service`. +- Sentry gets no `release` from lite-bootstrap, and sentry-sdk picks one itself, from `SENTRY_RELEASE`, the git revision or CI variables. A `service_version` you set is passed as `release` and wins over all of those. +- Everything else that needs a string uses `"micro-service"` and `"1.0.0"`, as before 2.0: the title and version of an app FastAPI builds for you, Litestar's OpenAPI title and version, Litestar's Prometheus `app_name` label, the health-check body, and Pyroscope's application name when `opentelemetry_service_name` is unset too. + ## Debug mode `service_debug` defaults to `False`. Debug mode puts tracebacks in error responses, so keep it off in production. @@ -16,6 +24,8 @@ To bootstrap Sentry, you must provide at least: - `sentry_dsn` - tells sentry-sdk where to send the events. +`service_version`, when set, is passed as `release`; see [Service name and version](#service-name-and-version). + Additional parameters can also be supplied through the settings object: - `sentry_traces_sample_rate` - in the range of 0.0 to 1.0, the percentage chance a given transaction will be sent @@ -295,8 +305,8 @@ OTEL_EXPERIMENTAL_RESOURCE_DETECTORS=process,os The attributes lite-bootstrap sets (`service.name`, `service.namespace`, `service.version`, `container.name`, and `deployment.environment.name` from `service_environment`) are merged last and override anything a detector or environment variable supplies. -`service_name` defaults to `"micro-service"`, so `OTEL_SERVICE_NAME` is overridden unless `service_name` -is empty: set `opentelemetry_service_name` or `service_name` instead. +`service_name` and `service_version` default to `None`, and an unset attribute is left out, so +`OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES` can supply it. ## Pyroscope diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index baeb008..2f58f04 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -93,9 +93,9 @@ def __post_init__(self) -> None: # FastAPIConfig stays frozen for user-facing immutability; __post_init__ needs # to set application after construction, so we bypass the freeze here. object.__setattr__(self, "application", application) - application.title = self.service_name + application.title = self.service_name_or_default application.debug = self.service_debug - application.version = self.service_version + application.version = self.service_version_or_default elif self.application_kwargs: warn_at_caller("application_kwargs must be used without application") diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index d3ff000..95ccd7d 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -414,7 +414,7 @@ class LitestarPrometheusController(PrometheusController): "group_path": config.prometheus_group_path, **config.prometheus_additional_params, } - litestar_prometheus_config = PrometheusConfig(app_name=config.service_name, **prometheus_params) + litestar_prometheus_config = PrometheusConfig(app_name=config.service_name_or_default, **prometheus_params) config.application_config.route_handlers.append(LitestarPrometheusController) if config.is_prometheus_http_metrics_on( @@ -466,8 +466,8 @@ def bootstrap(self) -> None: ) config.application_config.openapi_config = OpenAPIConfig( path=config.swagger_path, - title=config.service_name, - version=config.service_version, + title=config.service_name_or_default, + version=config.service_version_or_default, description=config.service_description, render_plugins=render_plugins, **config.swagger_additional_params, diff --git a/lite_bootstrap/instruments/base.py b/lite_bootstrap/instruments/base.py index a0991a1..62fb2ed 100644 --- a/lite_bootstrap/instruments/base.py +++ b/lite_bootstrap/instruments/base.py @@ -7,16 +7,28 @@ from lite_bootstrap.types import UNSET, UnsetType +_DEFAULT_SERVICE_NAME: typing.Final = "micro-service" +_DEFAULT_SERVICE_VERSION: typing.Final = "1.0.0" + + @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class BaseConfig: - service_name: str = "micro-service" + service_name: str | None = None service_description: str | None = None - service_version: str = "1.0.0" + service_version: str | None = None service_environment: str | None = None service_debug: bool = False # Deprecated field name -> the field that replaced it. The deprecated field defaults to UNSET. _renamed_fields: typing.ClassVar[Mapping[str, str]] = {} + @property + def service_name_or_default(self) -> str: + return self.service_name or _DEFAULT_SERVICE_NAME + + @property + def service_version_or_default(self) -> str: + return self.service_version or _DEFAULT_SERVICE_VERSION + def __post_init__(self) -> None: """Terminate the MRO __post_init__ cascade safely, after forwarding deprecated field names. diff --git a/lite_bootstrap/instruments/healthchecks_instrument.py b/lite_bootstrap/instruments/healthchecks_instrument.py index fd6c178..362ba40 100644 --- a/lite_bootstrap/instruments/healthchecks_instrument.py +++ b/lite_bootstrap/instruments/healthchecks_instrument.py @@ -30,8 +30,8 @@ class HealthChecksConfig(BaseConfig): @property def health_check_data(self) -> HealthCheckTypedDict: return { - "service_version": self.service_version, - "service_name": self.service_name, + "service_version": self.service_version_or_default, + "service_name": self.service_name_or_default, "health_status": True, } diff --git a/lite_bootstrap/instruments/pyroscope_instrument.py b/lite_bootstrap/instruments/pyroscope_instrument.py index 1db5de5..3f02e30 100644 --- a/lite_bootstrap/instruments/pyroscope_instrument.py +++ b/lite_bootstrap/instruments/pyroscope_instrument.py @@ -41,7 +41,7 @@ def bootstrap(self) -> None: namespace = config.opentelemetry_namespace tags = ({"service_namespace": namespace} if namespace else {}) | config.pyroscope_tags pyroscope.configure( - application_name=config.opentelemetry_service_name or config.service_name, + application_name=config.opentelemetry_service_name or config.service_name_or_default, server_address=config.pyroscope_endpoint, sample_rate=config.pyroscope_sample_rate, tags=tags, diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index 8b596a1..2dcf8bf 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -250,6 +250,8 @@ def _build_init_params(self) -> dict[str, typing.Any]: enrich_sentry_event_from_structlog_log, config.sentry_before_send ), } + if config.service_version: + init_params["release"] = config.service_version init_params.update(config.sentry_additional_params) return init_params diff --git a/tests/instruments/test_opentelemetry_instrument.py b/tests/instruments/test_opentelemetry_instrument.py index 66cc0ad..eebb2ce 100644 --- a/tests/instruments/test_opentelemetry_instrument.py +++ b/tests/instruments/test_opentelemetry_instrument.py @@ -1,3 +1,4 @@ +import dataclasses import logging import os import re @@ -126,7 +127,7 @@ def test_opentelemetry_sampler_wins_over_sampler_env_vars(monkeypatch: pytest.Mo @pytest.mark.parametrize( ("config", "expected_service_name"), [ - (OpenTelemetryConfig(opentelemetry_log_traces=True), "micro-service"), + (OpenTelemetryConfig(opentelemetry_log_traces=True), "from-env"), (OpenTelemetryConfig(opentelemetry_log_traces=True, service_name="configured"), "configured"), ( OpenTelemetryConfig( @@ -136,7 +137,7 @@ def test_opentelemetry_sampler_wins_over_sampler_env_vars(monkeypatch: pytest.Mo ), ], ) -def test_opentelemetry_configured_service_name_wins_over_env( +def test_opentelemetry_service_name_falls_back_to_otel_service_name( monkeypatch: pytest.MonkeyPatch, config: OpenTelemetryConfig, expected_service_name: str ) -> None: monkeypatch.setenv("OTEL_SERVICE_NAME", "from-env") @@ -149,6 +150,23 @@ def test_opentelemetry_configured_service_name_wins_over_env( instrument.teardown() +@pytest.mark.parametrize(("service_version", "expected_service_version"), [("2.3.4", "2.3.4"), (None, "from-env")]) +def test_opentelemetry_service_version_falls_back_to_otel_resource_attributes( + monkeypatch: pytest.MonkeyPatch, service_version: str | None, expected_service_version: str +) -> None: + monkeypatch.setenv("OTEL_RESOURCE_ATTRIBUTES", "service.version=from-env") + config = OpenTelemetryConfig(opentelemetry_log_traces=True) + if service_version is not None: + config = dataclasses.replace(config, service_version=service_version) + instrument = OpenTelemetryInstrument(bootstrap_config=config) + try: + instrument.bootstrap() + assert instrument._tracer_provider is not None # noqa: SLF001 + assert instrument._tracer_provider.resource.attributes["service.version"] == expected_service_version # noqa: SLF001 + finally: + instrument.teardown() + + @pytest.mark.parametrize(("service_environment", "expected"), [("prod", "prod"), (None, None)]) def test_opentelemetry_resource_carries_deployment_environment( service_environment: str | None, expected: str | None diff --git a/tests/instruments/test_pyroscope_instrument.py b/tests/instruments/test_pyroscope_instrument.py index c29a448..a41f533 100644 --- a/tests/instruments/test_pyroscope_instrument.py +++ b/tests/instruments/test_pyroscope_instrument.py @@ -75,6 +75,13 @@ def test_pyroscope_bootstrap_uses_opentelemetry_service_name() -> None: assert mock_pyroscope.configure.call_args.kwargs["application_name"] == "otel-name" +def test_pyroscope_application_name_falls_back_when_service_name_unset() -> None: + config = FreeConfig(pyroscope_endpoint="http://pyroscope:4040") + with patch(_PYROSCOPE_PYROSCOPE) as mock_pyroscope: + PyroscopeInstrument(bootstrap_config=config).bootstrap() + assert mock_pyroscope.configure.call_args.kwargs["application_name"] == "micro-service" + + def test_pyroscope_standalone_config_accepts_otel_fields() -> None: config = PyroscopeConfig( service_name="fallback", diff --git a/tests/instruments/test_sentry_instrument.py b/tests/instruments/test_sentry_instrument.py index 0f6002c..91a4b49 100644 --- a/tests/instruments/test_sentry_instrument.py +++ b/tests/instruments/test_sentry_instrument.py @@ -463,3 +463,34 @@ def test_sentry_additional_params_auto_enabling_integrations_wins_over_the_field ) assert _active_integrations(bootstrap_config) >= _AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT + + +@pytest.mark.parametrize( + ("service_version", "sentry_release_env", "expected_release"), + [ + pytest.param("2.3.4", None, "2.3.4", id="service-version"), + pytest.param("2.3.4", "from-env", "2.3.4", id="service-version-wins-over-env"), + pytest.param(None, "from-env", "from-env", id="env-when-unset"), + ], +) +def test_sentry_release_follows_service_version( + monkeypatch: pytest.MonkeyPatch, + minimal_sentry_config: SentryConfig, + service_version: str | None, + sentry_release_env: str | None, + expected_release: str, +) -> None: + if sentry_release_env is None: + monkeypatch.delenv("SENTRY_RELEASE", raising=False) + else: + monkeypatch.setenv("SENTRY_RELEASE", sentry_release_env) + config = minimal_sentry_config + if service_version is not None: + config = dataclasses.replace(config, service_version=service_version) + instrument = SentryInstrument(bootstrap_config=config) + instrument.bootstrap() + + try: + assert sentry_sdk.get_client().options["release"] == expected_release + finally: + instrument.teardown() diff --git a/tests/test_config.py b/tests/test_config.py index cb5bdf7..ff1d722 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -57,11 +57,11 @@ def test_config_from_object() -> None: def test_from_object_skips_none_attribute() -> None: @dataclasses.dataclass class Source: - service_name: str | None = None + service_debug: bool | None = None service_version: str = "2.0.0" config = BaseConfig.from_object(Source()) - assert config.service_name == "micro-service" + assert config.service_debug is False assert config.service_version == "2.0.0" @@ -70,8 +70,8 @@ class Source: pass config = BaseConfig.from_object(Source()) - assert config.service_name == "micro-service" - assert config.service_version == "1.0.0" + assert config.service_name is None + assert config.service_version is None assert config.service_debug is False @@ -89,12 +89,12 @@ class Source: def test_from_dict_drops_unknown_keys_silently() -> None: config = BaseConfig.from_dict({"service_name": "test", "unknown_key": "value"}) assert config.service_name == "test" - assert config.service_version == "1.0.0" + assert config.service_version is None def test_from_dict_explicit_none_overrides_default() -> None: - config = BaseConfig.from_dict({"service_name": None}) - assert config.service_name is None + config = BaseConfig.from_dict({"service_debug": None}) + assert config.service_debug is None @pytest.mark.parametrize("config_cls", [FreeConfig, LitestarConfig, FastStreamConfig, FastAPIConfig]) @@ -107,3 +107,17 @@ def test_otel_insecure_warning_fires_through_config_cascade(config_cls: type) -> ) matching = [w for w in caught if "unencrypted" in str(w.message)] assert matching, f"{config_cls.__name__}: {[str(w.message) for w in caught]}" + + +@pytest.mark.parametrize( + ("config", "expected_name", "expected_version"), + [ + pytest.param(BaseConfig(), "micro-service", "1.0.0", id="unset"), + pytest.param(BaseConfig(service_name="svc", service_version="2.3.4"), "svc", "2.3.4", id="set"), + ], +) +def test_service_name_and_version_fall_back_for_readers_that_need_a_string( + config: BaseConfig, expected_name: str, expected_version: str +) -> None: + assert config.service_name_or_default == expected_name + assert config.service_version_or_default == expected_version diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index 8efe798..b756735 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -658,3 +658,9 @@ def test_fastapi_health_check_runs_probes( "service_version": "2.0.0", } assert failed_probe_names(probe_warnings) == expected_failed + + +def test_built_app_title_and_version_fall_back_when_unset() -> None: + application = FastAPIConfig().app + assert application.title == "micro-service" + assert application.version == "1.0.0" diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 47bc6ce..4c01ac2 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -837,3 +837,13 @@ def test_litestar_health_check_runs_probes( "service_version": "2.0.0", } assert failed_probe_names(probe_warnings) == expected_failed + + +def test_litestar_openapi_and_prometheus_fall_back_to_default_service_name() -> None: + application = LitestarBootstrapper(bootstrap_config=LitestarConfig(logging_buffer_capacity=0)).bootstrap() + assert application.openapi_config is not None + assert application.openapi_config.title == "micro-service" + assert application.openapi_config.version == "1.0.0" + with TestClient(app=application) as client: + client.get("/health/") + assert 'app_name="micro-service"' in client.get("/metrics").text