015 add tracing

This commit is contained in:
Раис Юсупалиев
2026-03-14 00:28:51 +03:00
parent 7c5be003c1
commit 66beab4682
19 changed files with 507 additions and 9 deletions
+9
View File
@@ -48,6 +48,13 @@ class AdapterConfig(BaseModel):
cdek_cache_ttl_seconds: int = Field(default=900, gt=0) 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): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
extra="ignore", extra="ignore",
@@ -58,6 +65,7 @@ class Settings(BaseSettings):
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig) business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
repository: RepositoryConfig = Field(default_factory=RepositoryConfig) repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
adapter: AdapterConfig = Field(default_factory=AdapterConfig) adapter: AdapterConfig = Field(default_factory=AdapterConfig)
observability: ObservabilityConfig
@classmethod @classmethod
def settings_customise_sources( def settings_customise_sources(
@@ -85,6 +93,7 @@ class _RequiredYamlSections(BaseModel):
business_logic: dict[str, Any] business_logic: dict[str, Any]
repository: dict[str, Any] repository: dict[str, Any]
adapter: dict[str, Any] adapter: dict[str, Any]
observability: dict[str, Any]
def _resolve_runtime_config_file() -> str: def _resolve_runtime_config_file() -> str:
+2
View File
@@ -5,6 +5,7 @@ from fastapi import FastAPI
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.controllers.v1.delivery import router as delivery_router from app.controllers.v1.delivery import router as delivery_router
from app.runtime.logging import configure_logging from app.runtime.logging import configure_logging
from app.runtime.tracing import configure_tracing
def create_app(settings: Settings | None = None) -> FastAPI: 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.state.settings = resolved_settings
application.include_router(delivery_router, prefix=resolved_settings.controller.api_prefix) application.include_router(delivery_router, prefix=resolved_settings.controller.api_prefix)
configure_tracing(application, resolved_settings.observability)
return application return application
+75
View File
@@ -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)
+6
View File
@@ -22,3 +22,9 @@ adapter:
cdek_retry_backoff_seconds: 0.2 cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
observability:
enabled: false
service_name: "g2s-aggregator"
otlp_endpoint: "http://localhost:4317"
otlp_insecure: true
+6
View File
@@ -22,3 +22,9 @@ adapter:
cdek_retry_backoff_seconds: 0.2 cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
observability:
enabled: false
service_name: "g2s-aggregator-test"
otlp_endpoint: "http://localhost:4317"
otlp_insecure: true
+4 -3
View File
@@ -1,7 +1,7 @@
# Spec Tasks Index # Spec Tasks Index
> ⚠️ This file is generated. Do not edit manually. > ⚠️ 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 ## 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` | | 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` | | 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` | | 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 ## Summary
- Total: **15** - Total: **16**
- TODO: **0** - TODO: **1**
- DONE: **15** - DONE: **15**
@@ -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`
@@ -324,6 +324,12 @@ adapter:
cdek_retry_backoff_seconds: 0.1 cdek_retry_backoff_seconds: 0.1
cdek_timeout_seconds: 7.5 cdek_timeout_seconds: 7.5
cdek_cache_ttl_seconds: 777 cdek_cache_ttl_seconds: 777
observability:
enabled: false
service_name: "cdek-adapter-test-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
""".strip(), """.strip(),
encoding="utf-8", encoding="utf-8",
) )
@@ -14,3 +14,9 @@ repository:
adapter: adapter:
cdek_client_id: "yaml-id" cdek_client_id: "yaml-id"
cdek_client_secret: "yaml-secret" cdek_client_secret: "yaml-secret"
observability:
enabled: false
service_name: "default-config-service"
otlp_endpoint: "http://default-collector:4317"
otlp_insecure: true
@@ -22,3 +22,9 @@ adapter:
cdek_retry_backoff_seconds: 0.2 cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
observability:
enabled: false
service_name: "invalid-price-multiplier-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
@@ -11,3 +11,9 @@ business_logic:
repository: repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
observability:
enabled: false
service_name: "missing-adapter-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
@@ -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
@@ -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
@@ -21,3 +21,9 @@ adapter:
cdek_retry_backoff_seconds: 0.2 cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
observability:
enabled: false
service_name: "missing-price-multiplier-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
@@ -14,3 +14,9 @@ repository:
adapter: adapter:
cdek_client_id: "test-id" cdek_client_id: "test-id"
cdek_client_secret: "test-secret" cdek_client_secret: "test-secret"
observability:
enabled: true
service_name: "override-config-service"
otlp_endpoint: "http://override-collector:4317"
otlp_insecure: false
+51
View File
@@ -24,6 +24,16 @@ INVALID_PRICE_MULTIPLIER_CONFIG_FILE = (
MISSING_PRICE_MULTIPLIER_CONFIG_FILE = ( MISSING_PRICE_MULTIPLIER_CONFIG_FILE = (
PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.missing_price_multiplier.yaml" 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( 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_retry_backoff_seconds == 0.2
assert settings.adapter.cdek_timeout_seconds == 10.0 assert settings.adapter.cdek_timeout_seconds == 10.0
assert settings.adapter.cdek_cache_ttl_seconds == 900 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( 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 is second
assert first.service.provider_timeout_seconds == 10.0 assert first.service.provider_timeout_seconds == 10.0
assert first.business_logic.provider_price_multiplier == Decimal("1.0") assert first.business_logic.provider_price_multiplier == Decimal("1.0")
assert first.observability.service_name == "g2s-aggregator-test"
get_settings.cache_clear() 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.controller.api_prefix == "/from-config-test-yaml"
assert settings.business_logic.provider_price_multiplier == Decimal("1.25") assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
assert settings.adapter.cdek_client_id == "test-id" 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() 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()} locations = {tuple(item["loc"]) for item in error.value.errors()}
assert ("business_logic", "provider_price_multiplier") in locations assert ("business_logic", "provider_price_multiplier") in locations
get_settings.cache_clear() 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()
+6
View File
@@ -178,6 +178,12 @@ adapter:
cdek_retry_backoff_seconds: 0.2 cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
observability:
enabled: false
service_name: "repository-test-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
""".strip(), """.strip(),
encoding="utf-8", encoding="utf-8",
) )
+24 -6
View File
@@ -7,6 +7,7 @@ from fastapi import FastAPI
from app import config as config_module from app import config as config_module
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.runtime import logging as logging_module 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" 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: 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: 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(logging_module, "configure_logging", fake_configure_logging)
monkeypatch.setattr(tracing_module, "configure_tracing", fake_configure_tracing)
main_module = _import_main_module(monkeypatch) main_module = _import_main_module(monkeypatch)
assert isinstance(main_module.app, FastAPI) assert isinstance(main_module.app, FastAPI)
assert isinstance(main_module.app.state.settings, Settings) 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() get_settings.cache_clear()
def test_app_created_with_provided_settings(monkeypatch) -> None: 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: 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(logging_module, "configure_logging", fake_configure_logging)
monkeypatch.setattr(tracing_module, "configure_tracing", fake_configure_tracing)
main_module = _import_main_module(monkeypatch) main_module = _import_main_module(monkeypatch)
monkeypatch.setattr(config_module, "TEST_CONFIG_FILE", str(TEST_CONFIG_FILE)) 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) instance = main_module.create_app(settings=settings)
assert instance.state.settings is 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() get_settings.cache_clear()
+185
View File
@@ -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