015 add tracing
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user