diff --git a/app/config.py b/app/config.py index 9266b7c..b3a1938 100644 --- a/app/config.py +++ b/app/config.py @@ -48,6 +48,13 @@ class AdapterConfig(BaseModel): cdek_cache_ttl_seconds: int = Field(default=900, gt=0) +class ObservabilityConfig(BaseModel): + enabled: bool = False + service_name: str = Field(..., min_length=1) + otlp_endpoint: str = Field(..., min_length=1) + otlp_insecure: bool = True + + class Settings(BaseSettings): model_config = SettingsConfigDict( extra="ignore", @@ -58,6 +65,7 @@ class Settings(BaseSettings): business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig) repository: RepositoryConfig = Field(default_factory=RepositoryConfig) adapter: AdapterConfig = Field(default_factory=AdapterConfig) + observability: ObservabilityConfig @classmethod def settings_customise_sources( @@ -85,6 +93,7 @@ class _RequiredYamlSections(BaseModel): business_logic: dict[str, Any] repository: dict[str, Any] adapter: dict[str, Any] + observability: dict[str, Any] def _resolve_runtime_config_file() -> str: diff --git a/app/main.py b/app/main.py index f1b2f22..f36c79b 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from app.config import Settings, get_settings from app.controllers.v1.delivery import router as delivery_router from app.runtime.logging import configure_logging +from app.runtime.tracing import configure_tracing def create_app(settings: Settings | None = None) -> FastAPI: @@ -15,6 +16,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: application.state.settings = resolved_settings application.include_router(delivery_router, prefix=resolved_settings.controller.api_prefix) + configure_tracing(application, resolved_settings.observability) return application diff --git a/app/runtime/tracing.py b/app/runtime/tracing.py new file mode 100644 index 0000000..9fc6127 --- /dev/null +++ b/app/runtime/tracing.py @@ -0,0 +1,75 @@ +"""Centralized runtime OpenTelemetry tracing bootstrap.""" + +from fastapi import FastAPI +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.instrumentation.redis import RedisInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from app.config import ObservabilityConfig + +_HTTPX_INSTRUMENTOR = HTTPXClientInstrumentor() +_REDIS_INSTRUMENTOR = RedisInstrumentor() +_TRACER_PROVIDER: TracerProvider | None = None + + +def configure_tracing(app: FastAPI, observability: ObservabilityConfig) -> None: + if not observability.enabled: + return + + tracer_provider = _get_or_create_tracer_provider(observability) + _instrument_fastapi_app(app, tracer_provider=tracer_provider) + _instrument_httpx(tracer_provider=tracer_provider) + _instrument_redis(tracer_provider=tracer_provider) + + +def _get_or_create_tracer_provider(observability: ObservabilityConfig) -> TracerProvider: + global _TRACER_PROVIDER + + if _TRACER_PROVIDER is not None: + return _TRACER_PROVIDER + + tracer_provider = _build_tracer_provider(observability) + trace.set_tracer_provider(tracer_provider) + _TRACER_PROVIDER = tracer_provider + return tracer_provider + + +def _build_tracer_provider(observability: ObservabilityConfig) -> TracerProvider: + exporter = _build_span_exporter(observability) + tracer_provider = TracerProvider(resource=_build_resource(observability)) + tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) + return tracer_provider + + +def _build_span_exporter(observability: ObservabilityConfig) -> OTLPSpanExporter: + return OTLPSpanExporter( + endpoint=observability.otlp_endpoint, + insecure=observability.otlp_insecure, + ) + + +def _build_resource(observability: ObservabilityConfig) -> Resource: + return Resource.create({"service.name": observability.service_name}) + + +def _instrument_fastapi_app(app: FastAPI, *, tracer_provider: TracerProvider) -> None: + if getattr(app, "_is_instrumented_by_opentelemetry", False): + return + FastAPIInstrumentor.instrument_app(app, tracer_provider=tracer_provider) + + +def _instrument_httpx(*, tracer_provider: TracerProvider) -> None: + if _HTTPX_INSTRUMENTOR.is_instrumented_by_opentelemetry: + return + _HTTPX_INSTRUMENTOR.instrument(tracer_provider=tracer_provider) + + +def _instrument_redis(*, tracer_provider: TracerProvider) -> None: + if _REDIS_INSTRUMENTOR.is_instrumented_by_opentelemetry: + return + _REDIS_INSTRUMENTOR.instrument(tracer_provider=tracer_provider) diff --git a/config.example.yaml b/config.example.yaml index 017b996..6da1b00 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -22,3 +22,9 @@ adapter: cdek_retry_backoff_seconds: 0.2 cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 + +observability: + enabled: false + service_name: "g2s-aggregator" + otlp_endpoint: "http://localhost:4317" + otlp_insecure: true diff --git a/config.test.yaml b/config.test.yaml index 06525c4..c1170d5 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -22,3 +22,9 @@ adapter: cdek_retry_backoff_seconds: 0.2 cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 + +observability: + enabled: false + service_name: "g2s-aggregator-test" + otlp_endpoint: "http://localhost:4317" + otlp_insecure: true diff --git a/spec/index.md b/spec/index.md index 194cb44..1d98bff 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,7 +1,7 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-13T14:18:34+00:00` +> Generated at (UTC): `2026-03-13T14:21:47+00:00` ## Tasks @@ -22,9 +22,10 @@ | 012 | DONE | 2026-03-08 | Remove observability and SigNoz stack for phase 1 | `spec/tasks/012_remove_observability_and_signoz_for_phase1.md` | | 013 | DONE | 2026-03-09 | Add configurable provider price multiplier in domain logic | `spec/tasks/013_add_configurable_provider_price_multiplier.md` | | 014 | DONE | 2026-03-12 | Add minimal structlog JSON logging | `spec/tasks/014_add_minimal_structlog_json_logging.md` | +| 015 | TODO | 2026-03-13 | Add minimal OpenTelemetry tracing | `spec/tasks/015_add_minimal_opentelemetry_tracing.md` | ## Summary -- Total: **15** -- TODO: **0** +- Total: **16** +- TODO: **1** - DONE: **15** diff --git a/spec/tasks/015_add_minimal_opentelemetry_tracing.md b/spec/tasks/015_add_minimal_opentelemetry_tracing.md new file mode 100644 index 0000000..f56b1c8 --- /dev/null +++ b/spec/tasks/015_add_minimal_opentelemetry_tracing.md @@ -0,0 +1,50 @@ +--- +id: 015 +title: Add minimal OpenTelemetry tracing +status: TODO +created: 2026-03-13 +--- + +## Context +После удаления observability stack в задаче `012` runtime-приложение осталось без трассировки. Появилось новое требование: вернуть только минимальный tracing для входящих HTTP-запросов и исходящих вызовов провайдера/Redis, без возврата metrics, alerting и trace/log correlation. + +## Goal +Подключить минимальный OpenTelemetry tracing через централизованный runtime bootstrap: настроить `TracerProvider` и OTLP exporter из YAML-конфига, добавить instrumentation для FastAPI, httpx и Redis и встроить этот bootstrap в startup приложения без переноса telemetry concern в Business Logic. + +## Constraints +- Соблюдать layered architecture из `AGENTS.md`. +- Scope задачи: только tracing bootstrap, YAML configuration, instrumentation wiring и тесты на это поведение. +- Не добавлять metrics, request correlation middleware, `request_id`, `trace_id` в логи, SigNoz, Telegram alerting и иные observability features кроме tracing. +- Не добавлять business rules, branching или data access в runtime tracing bootstrap. +- Инициализация OpenTelemetry должна жить в отдельном runtime-модуле или app startup; Controller, Service, Repository, Adapter и Business Logic не должны вручную создавать exporter/provider. +- Конфигурация tracing должна читаться из отдельной секции `observability` в YAML-файлах конфигурации. +- Bootstrap tracing должен быть идемпотентным: повторные вызовы `create_app()` в тестах не должны дублировать global instrumentation и span processors. +- Не изменять API contract, логику расчета тарифов, кэш semantics и provider protocol. +- Не изменять файлы в `spec/`. + +## Acceptance criteria +- В конфигурации приложения добавлена секция `observability` с параметрами включения tracing, `service_name`, OTLP endpoint и флагом insecure transport. +- При `observability.enabled = true` приложение на старте создает `TracerProvider` с `Resource(service.name=...)` и OTLP exporter, используя значения из YAML-конфига. +- При `observability.enabled = true` централизованно включается instrumentation для FastAPI, httpx и Redis. +- При `observability.enabled = false` приложение стартует без инициализации exporter/provider и без регистрации instrumentation. +- Повторное создание приложения или повторный вызов tracing bootstrap не приводит к duplicate instrumentation/span processor registration. +- Реализация не восстанавливает metrics, alerts, request/log correlation и не меняет JSON logging contract. + +## Definition of Done +- [ ] Добавлена секция `observability` в config schema и runtime YAML-файлы. +- [ ] Реализован централизованный runtime bootstrap для OpenTelemetry tracing. +- [ ] `create_app()` подключает tracing bootstrap без бизнес-логики и без дублирования инициализации. +- [ ] Добавлены tests на конфигурацию, enabled/disabled режимы и идемпотентность tracing setup. +- [ ] Добавлен tracing test, подтверждающий создание spans для FastAPI/httpx/Redis instrumentation или корректный wiring этих instrumentors. + +## Tests +- Обновить `tests/config/test_config_sections.py` для проверки секции `observability`, обязательных полей и disabled/enabled конфигурации. +- Обновить `tests/smoke/test_app_import.py` для проверки вызова централизованного tracing bootstrap при создании app. +- Добавить `tests/tracing/test_bootstrap.py` для проверки OTLP exporter/provider setup, `service.name`, enabled/disabled режимов и идемпотентности instrumentation. +- Добавить tracing test с in-memory exporter или эквивалентным deterministic harness для проверки, что FastAPI/httpx/Redis instrumentation корректно подключается без запуска внешнего OTLP collector. + +## Commands +- `poetry run pytest tests/config/test_config_sections.py -q` +- `poetry run pytest tests/smoke/test_app_import.py -q` +- `poetry run pytest tests/tracing/test_bootstrap.py -q` +- `python3 spec/gen_spec_index.py --check` diff --git a/tests/adapters/delivery_providers/cdek/test_client.py b/tests/adapters/delivery_providers/cdek/test_client.py index 86daa06..8cc9ae8 100644 --- a/tests/adapters/delivery_providers/cdek/test_client.py +++ b/tests/adapters/delivery_providers/cdek/test_client.py @@ -324,6 +324,12 @@ adapter: cdek_retry_backoff_seconds: 0.1 cdek_timeout_seconds: 7.5 cdek_cache_ttl_seconds: 777 + +observability: + enabled: false + service_name: "cdek-adapter-test-service" + otlp_endpoint: "http://collector:4317" + otlp_insecure: true """.strip(), encoding="utf-8", ) diff --git a/tests/config/fixtures/config.default.yaml b/tests/config/fixtures/config.default.yaml index 53d9751..ffe4e41 100644 --- a/tests/config/fixtures/config.default.yaml +++ b/tests/config/fixtures/config.default.yaml @@ -14,3 +14,9 @@ repository: adapter: cdek_client_id: "yaml-id" cdek_client_secret: "yaml-secret" + +observability: + enabled: false + service_name: "default-config-service" + otlp_endpoint: "http://default-collector:4317" + otlp_insecure: true diff --git a/tests/config/fixtures/config.invalid_price_multiplier.yaml b/tests/config/fixtures/config.invalid_price_multiplier.yaml index 50088f7..9645ed7 100644 --- a/tests/config/fixtures/config.invalid_price_multiplier.yaml +++ b/tests/config/fixtures/config.invalid_price_multiplier.yaml @@ -22,3 +22,9 @@ adapter: cdek_retry_backoff_seconds: 0.2 cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 + +observability: + enabled: false + service_name: "invalid-price-multiplier-service" + otlp_endpoint: "http://collector:4317" + otlp_insecure: true diff --git a/tests/config/fixtures/config.missing_adapter.yaml b/tests/config/fixtures/config.missing_adapter.yaml index 609fc1e..a7ce178 100644 --- a/tests/config/fixtures/config.missing_adapter.yaml +++ b/tests/config/fixtures/config.missing_adapter.yaml @@ -11,3 +11,9 @@ business_logic: repository: redis_dsn: "redis://localhost:6379/0" price_cache_ttl_seconds: 900 + +observability: + enabled: false + service_name: "missing-adapter-service" + otlp_endpoint: "http://collector:4317" + otlp_insecure: true diff --git a/tests/config/fixtures/config.missing_observability.yaml b/tests/config/fixtures/config.missing_observability.yaml new file mode 100644 index 0000000..06525c4 --- /dev/null +++ b/tests/config/fixtures/config.missing_observability.yaml @@ -0,0 +1,24 @@ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + provider_price_multiplier: 1.0 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + +adapter: + cdek_base_url: "https://api.cdek.ru/v2" + cdek_client_id: "test-client-id" + cdek_client_secret: "test-client-secret" + cdek_retry_attempts: 2 + cdek_retry_backoff_seconds: 0.2 + cdek_timeout_seconds: 10.0 + cdek_cache_ttl_seconds: 900 diff --git a/tests/config/fixtures/config.missing_observability_endpoint.yaml b/tests/config/fixtures/config.missing_observability_endpoint.yaml new file mode 100644 index 0000000..52a52f7 --- /dev/null +++ b/tests/config/fixtures/config.missing_observability_endpoint.yaml @@ -0,0 +1,29 @@ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + provider_price_multiplier: 1.0 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + +adapter: + cdek_base_url: "https://api.cdek.ru/v2" + cdek_client_id: "test-client-id" + cdek_client_secret: "test-client-secret" + cdek_retry_attempts: 2 + cdek_retry_backoff_seconds: 0.2 + cdek_timeout_seconds: 10.0 + cdek_cache_ttl_seconds: 900 + +observability: + enabled: true + service_name: "g2s-aggregator" + otlp_insecure: true diff --git a/tests/config/fixtures/config.missing_price_multiplier.yaml b/tests/config/fixtures/config.missing_price_multiplier.yaml index ff37899..6375deb 100644 --- a/tests/config/fixtures/config.missing_price_multiplier.yaml +++ b/tests/config/fixtures/config.missing_price_multiplier.yaml @@ -21,3 +21,9 @@ adapter: cdek_retry_backoff_seconds: 0.2 cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 + +observability: + enabled: false + service_name: "missing-price-multiplier-service" + otlp_endpoint: "http://collector:4317" + otlp_insecure: true diff --git a/tests/config/fixtures/config.test.override.yaml b/tests/config/fixtures/config.test.override.yaml index 390e9fd..c0a0f11 100644 --- a/tests/config/fixtures/config.test.override.yaml +++ b/tests/config/fixtures/config.test.override.yaml @@ -14,3 +14,9 @@ repository: adapter: cdek_client_id: "test-id" cdek_client_secret: "test-secret" + +observability: + enabled: true + service_name: "override-config-service" + otlp_endpoint: "http://override-collector:4317" + otlp_insecure: false diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index 3e3e0a7..fd7885b 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -24,6 +24,16 @@ INVALID_PRICE_MULTIPLIER_CONFIG_FILE = ( MISSING_PRICE_MULTIPLIER_CONFIG_FILE = ( PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.missing_price_multiplier.yaml" ) +MISSING_OBSERVABILITY_CONFIG_FILE = ( + PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.missing_observability.yaml" +) +MISSING_OBSERVABILITY_ENDPOINT_CONFIG_FILE = ( + PROJECT_ROOT + / "tests" + / "config" + / "fixtures" + / "config.missing_observability_endpoint.yaml" +) def _use_runtime_config_files( @@ -64,6 +74,10 @@ def test_configuration_sections_are_loaded_from_yaml_file( assert settings.adapter.cdek_retry_backoff_seconds == 0.2 assert settings.adapter.cdek_timeout_seconds == 10.0 assert settings.adapter.cdek_cache_ttl_seconds == 900 + assert settings.observability.enabled is False + assert settings.observability.service_name == "g2s-aggregator-test" + assert settings.observability.otlp_endpoint == "http://localhost:4317" + assert settings.observability.otlp_insecure is True def test_get_settings_fails_when_required_yaml_section_is_missing( @@ -91,6 +105,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) - assert first is second assert first.service.provider_timeout_seconds == 10.0 assert first.business_logic.provider_price_multiplier == Decimal("1.0") + assert first.observability.service_name == "g2s-aggregator-test" get_settings.cache_clear() @@ -109,6 +124,10 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment( assert settings.controller.api_prefix == "/from-config-test-yaml" assert settings.business_logic.provider_price_multiplier == Decimal("1.25") assert settings.adapter.cdek_client_id == "test-id" + assert settings.observability.enabled is True + assert settings.observability.service_name == "override-config-service" + assert settings.observability.otlp_endpoint == "http://override-collector:4317" + assert settings.observability.otlp_insecure is False get_settings.cache_clear() @@ -142,3 +161,35 @@ def test_get_settings_fails_when_provider_price_multiplier_is_missing( locations = {tuple(item["loc"]) for item in error.value.errors()} assert ("business_logic", "provider_price_multiplier") in locations get_settings.cache_clear() + + +def test_get_settings_fails_when_observability_section_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_runtime_config_files( + monkeypatch, + test_config_file=MISSING_OBSERVABILITY_CONFIG_FILE, + ) + + with pytest.raises(ValidationError) as error: + get_settings() + + locations = {tuple(item["loc"]) for item in error.value.errors()} + assert ("observability",) in locations + get_settings.cache_clear() + + +def test_get_settings_fails_when_observability_endpoint_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_runtime_config_files( + monkeypatch, + test_config_file=MISSING_OBSERVABILITY_ENDPOINT_CONFIG_FILE, + ) + + with pytest.raises(ValidationError) as error: + get_settings() + + locations = {tuple(item["loc"]) for item in error.value.errors()} + assert ("observability", "otlp_endpoint") in locations + get_settings.cache_clear() diff --git a/tests/repositories/cache/test_redis_cache.py b/tests/repositories/cache/test_redis_cache.py index ff9e13b..6d6307e 100644 --- a/tests/repositories/cache/test_redis_cache.py +++ b/tests/repositories/cache/test_redis_cache.py @@ -178,6 +178,12 @@ adapter: cdek_retry_backoff_seconds: 0.2 cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 + +observability: + enabled: false + service_name: "repository-test-service" + otlp_endpoint: "http://collector:4317" + otlp_insecure: true """.strip(), encoding="utf-8", ) diff --git a/tests/smoke/test_app_import.py b/tests/smoke/test_app_import.py index af7920a..a8a6bff 100644 --- a/tests/smoke/test_app_import.py +++ b/tests/smoke/test_app_import.py @@ -7,6 +7,7 @@ from fastapi import FastAPI from app import config as config_module from app.config import Settings, get_settings from app.runtime import logging as logging_module +from app.runtime import tracing as tracing_module TEST_CONFIG_FILE = Path(__file__).resolve().parents[2] / "config.test.yaml" @@ -19,27 +20,40 @@ def _import_main_module(monkeypatch): def test_app_import_smoke(monkeypatch) -> None: - bootstrap_calls: list[None] = [] + logging_bootstrap_calls: list[None] = [] + tracing_bootstrap_calls: list[tuple[FastAPI, object]] = [] def fake_configure_logging() -> None: - bootstrap_calls.append(None) + logging_bootstrap_calls.append(None) + + def fake_configure_tracing(app: FastAPI, observability: object) -> None: + tracing_bootstrap_calls.append((app, observability)) monkeypatch.setattr(logging_module, "configure_logging", fake_configure_logging) + monkeypatch.setattr(tracing_module, "configure_tracing", fake_configure_tracing) main_module = _import_main_module(monkeypatch) assert isinstance(main_module.app, FastAPI) assert isinstance(main_module.app.state.settings, Settings) - assert bootstrap_calls == [None] + assert logging_bootstrap_calls == [None] + assert tracing_bootstrap_calls == [ + (main_module.app, main_module.app.state.settings.observability) + ] get_settings.cache_clear() def test_app_created_with_provided_settings(monkeypatch) -> None: - bootstrap_calls: list[None] = [] + logging_bootstrap_calls: list[None] = [] + tracing_bootstrap_calls: list[tuple[FastAPI, object]] = [] def fake_configure_logging() -> None: - bootstrap_calls.append(None) + logging_bootstrap_calls.append(None) + + def fake_configure_tracing(app: FastAPI, observability: object) -> None: + tracing_bootstrap_calls.append((app, observability)) monkeypatch.setattr(logging_module, "configure_logging", fake_configure_logging) + monkeypatch.setattr(tracing_module, "configure_tracing", fake_configure_tracing) main_module = _import_main_module(monkeypatch) monkeypatch.setattr(config_module, "TEST_CONFIG_FILE", str(TEST_CONFIG_FILE)) @@ -47,5 +61,9 @@ def test_app_created_with_provided_settings(monkeypatch) -> None: instance = main_module.create_app(settings=settings) assert instance.state.settings is settings - assert bootstrap_calls == [None, None] + assert logging_bootstrap_calls == [None, None] + assert tracing_bootstrap_calls == [ + (main_module.app, main_module.app.state.settings.observability), + (instance, settings.observability), + ] get_settings.cache_clear() diff --git a/tests/tracing/test_bootstrap.py b/tests/tracing/test_bootstrap.py new file mode 100644 index 0000000..5c16011 --- /dev/null +++ b/tests/tracing/test_bootstrap.py @@ -0,0 +1,185 @@ +import importlib + +from fastapi import FastAPI + +from app.config import ObservabilityConfig + + +def _reload_tracing_module(): + tracing_module = importlib.import_module("app.runtime.tracing") + return importlib.reload(tracing_module) + + +def _make_observability_config(**overrides) -> ObservabilityConfig: + payload = { + "enabled": True, + "service_name": "delivery-aggregator", + "otlp_endpoint": "http://collector:4317", + "otlp_insecure": True, + } + payload.update(overrides) + return ObservabilityConfig(**payload) + + +def test_configure_tracing_skips_bootstrap_when_disabled(monkeypatch) -> None: + tracing_module = _reload_tracing_module() + calls = {"provider": 0, "fastapi": 0, "httpx": 0, "redis": 0} + + def fake_get_or_create_tracer_provider(_observability): + calls["provider"] += 1 + return object() + + def fake_instrument_fastapi_app(_app, *, tracer_provider) -> None: + del tracer_provider + calls["fastapi"] += 1 + + def fake_instrument_httpx(*, tracer_provider) -> None: + del tracer_provider + calls["httpx"] += 1 + + def fake_instrument_redis(*, tracer_provider) -> None: + del tracer_provider + calls["redis"] += 1 + + monkeypatch.setattr( + tracing_module, + "_get_or_create_tracer_provider", + fake_get_or_create_tracer_provider, + ) + monkeypatch.setattr( + tracing_module, + "_instrument_fastapi_app", + fake_instrument_fastapi_app, + ) + monkeypatch.setattr(tracing_module, "_instrument_httpx", fake_instrument_httpx) + monkeypatch.setattr(tracing_module, "_instrument_redis", fake_instrument_redis) + + tracing_module.configure_tracing( + FastAPI(), + _make_observability_config(enabled=False), + ) + + assert calls == {"provider": 0, "fastapi": 0, "httpx": 0, "redis": 0} + + +def test_build_tracer_provider_uses_otlp_exporter_and_service_name(monkeypatch) -> None: + tracing_module = _reload_tracing_module() + created: dict[str, object] = {} + + class FakeExporter: + def __init__(self, *, endpoint: str, insecure: bool) -> None: + created["endpoint"] = endpoint + created["insecure"] = insecure + + class FakeBatchSpanProcessor: + def __init__(self, exporter: object) -> None: + self.exporter = exporter + created["processor_exporter"] = exporter + + class FakeTracerProvider: + def __init__(self, *, resource) -> None: + self.resource = resource + self.span_processors: list[object] = [] + + def add_span_processor(self, processor: object) -> None: + self.span_processors.append(processor) + + monkeypatch.setattr(tracing_module, "OTLPSpanExporter", FakeExporter) + monkeypatch.setattr(tracing_module, "BatchSpanProcessor", FakeBatchSpanProcessor) + monkeypatch.setattr(tracing_module, "TracerProvider", FakeTracerProvider) + + provider = tracing_module._build_tracer_provider( + _make_observability_config( + service_name="g2s-aggregator", + otlp_endpoint="http://trace-backend:4317", + otlp_insecure=False, + ) + ) + + assert created["endpoint"] == "http://trace-backend:4317" + assert created["insecure"] is False + assert provider.resource.attributes["service.name"] == "g2s-aggregator" + assert len(provider.span_processors) == 1 + assert created["processor_exporter"] is provider.span_processors[0].exporter + + +def test_configure_tracing_reuses_provider_and_global_instrumentation(monkeypatch) -> None: + tracing_module = _reload_tracing_module() + created = { + "exporters": 0, + "providers": 0, + "set_provider_calls": 0, + "fastapi_apps": [], + "httpx_calls": 0, + "redis_calls": 0, + } + + class FakeExporter: + def __init__(self, *, endpoint: str, insecure: bool) -> None: + created["exporters"] += 1 + self.endpoint = endpoint + self.insecure = insecure + + class FakeBatchSpanProcessor: + def __init__(self, exporter: object) -> None: + self.exporter = exporter + + class FakeTracerProvider: + def __init__(self, *, resource) -> None: + created["providers"] += 1 + self.resource = resource + self.span_processors: list[object] = [] + + def add_span_processor(self, processor: object) -> None: + self.span_processors.append(processor) + + class FakeInstrumentor: + def __init__(self, kind: str) -> None: + self.kind = kind + self.is_instrumented_by_opentelemetry = False + + def instrument(self, **kwargs) -> None: + assert kwargs["tracer_provider"] is not None + created[f"{self.kind}_calls"] += 1 + self.is_instrumented_by_opentelemetry = True + + class FakeFastAPIInstrumentor: + @staticmethod + def instrument_app(app: FastAPI, *, tracer_provider) -> None: + assert tracer_provider is not None + created["fastapi_apps"].append(app) + app._is_instrumented_by_opentelemetry = True + + def fake_set_tracer_provider(_provider: object) -> None: + created["set_provider_calls"] += 1 + + monkeypatch.setattr(tracing_module, "OTLPSpanExporter", FakeExporter) + monkeypatch.setattr(tracing_module, "BatchSpanProcessor", FakeBatchSpanProcessor) + monkeypatch.setattr(tracing_module, "TracerProvider", FakeTracerProvider) + monkeypatch.setattr( + tracing_module, + "_HTTPX_INSTRUMENTOR", + FakeInstrumentor("httpx"), + ) + monkeypatch.setattr( + tracing_module, + "_REDIS_INSTRUMENTOR", + FakeInstrumentor("redis"), + ) + monkeypatch.setattr(tracing_module, "FastAPIInstrumentor", FakeFastAPIInstrumentor) + monkeypatch.setattr(tracing_module.trace, "set_tracer_provider", fake_set_tracer_provider) + + app_one = FastAPI() + app_two = FastAPI() + observability = _make_observability_config() + + tracing_module.configure_tracing(app_one, observability) + tracing_module.configure_tracing(app_one, observability) + tracing_module.configure_tracing(app_two, observability) + + assert created["exporters"] == 1 + assert created["providers"] == 1 + assert created["set_provider_calls"] == 1 + assert created["fastapi_apps"] == [app_one, app_two] + assert created["httpx_calls"] == 1 + assert created["redis_calls"] == 1