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
@@ -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",
)
@@ -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
@@ -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
@@ -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
@@ -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_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
@@ -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
+51
View File
@@ -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()
+6
View File
@@ -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",
)
+24 -6
View File
@@ -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()
+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