пофикшен redis и логи redis
Deploy / deploy (push) Successful in 52s

This commit is contained in:
Раис Юсупалиев
2026-06-20 19:35:23 +03:00
parent 02f5ef93b0
commit 798b6e38bd
12 changed files with 352 additions and 9 deletions
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
echo "APP_PORT=8004" echo "APP_PORT=8004"
echo "POSTGRES_PORT=5433" echo "POSTGRES_PORT=5433"
echo "REDIS_PORT=6380" echo "REDIS_PORT=6380"
echo 'METRICS_FILTER_EXPRESSION=not IsMatch(resource.attributes["container.name"], "-stage$")' echo 'METRICS_FILTER_EXPRESSION=resource.attributes["container.name"] != nil and not IsMatch(resource.attributes["container.name"], "-stage$")'
echo "COMPOSE_PROJECT=g2s-aggregator-stage" echo "COMPOSE_PROJECT=g2s-aggregator-stage"
echo "DEPLOY_DIR=/home/deploy/g2s-aggregator-stage" echo "DEPLOY_DIR=/home/deploy/g2s-aggregator-stage"
} >> "$GITHUB_ENV" } >> "$GITHUB_ENV"
@@ -37,7 +37,7 @@ jobs:
echo "APP_PORT=8003" echo "APP_PORT=8003"
echo "POSTGRES_PORT=5432" echo "POSTGRES_PORT=5432"
echo "REDIS_PORT=6379" echo "REDIS_PORT=6379"
echo 'METRICS_FILTER_EXPRESSION=IsMatch(resource.attributes["container.name"], "-stage$")' echo 'METRICS_FILTER_EXPRESSION=resource.attributes["container.name"] != nil and IsMatch(resource.attributes["container.name"], "-stage$")'
echo "COMPOSE_PROJECT=g2s-aggregator" echo "COMPOSE_PROJECT=g2s-aggregator"
echo "DEPLOY_DIR=/home/deploy/g2s-aggregator" echo "DEPLOY_DIR=/home/deploy/g2s-aggregator"
} >> "$GITHUB_ENV" } >> "$GITHUB_ENV"
+5 -1
View File
@@ -18,6 +18,7 @@ from app.config import Settings
from app.controllers.http_client import build_controller_http_client from app.controllers.http_client import build_controller_http_client
from app.repositories.cache.redis_cache import PriceCache from app.repositories.cache.redis_cache import PriceCache
from app.repositories.order import OrderRepository from app.repositories.order import OrderRepository
from app.runtime.metrics import get_cache_metrics
from app.schemas.payment import ( from app.schemas.payment import (
InitPaymentRequest, InitPaymentRequest,
InitPaymentResponse, InitPaymentResponse,
@@ -75,7 +76,10 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
config=settings.address_suggestions.tomtom, config=settings.address_suggestions.tomtom,
) )
providers = (cdek_provider, cse_provider) providers = (cdek_provider, cse_provider)
cache = PriceCache.from_repository_config(settings.repository) cache = PriceCache.from_repository_config(
settings.repository,
metrics=get_cache_metrics(),
)
postgres_engine = create_postgres_engine(settings.postgres) postgres_engine = create_postgres_engine(settings.postgres)
postgres_session_factory = create_postgres_session_factory(postgres_engine) postgres_session_factory = create_postgres_session_factory(postgres_engine)
order_repository = OrderRepository(session_factory=postgres_session_factory) order_repository = OrderRepository(session_factory=postgres_session_factory)
+2
View File
@@ -5,12 +5,14 @@ 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.metrics import configure_metrics
from app.runtime.tracing import configure_tracing from app.runtime.tracing import configure_tracing
def create_app(settings: Settings | None = None) -> FastAPI: def create_app(settings: Settings | None = None) -> FastAPI:
configure_logging() configure_logging()
resolved_settings = settings or get_settings() resolved_settings = settings or get_settings()
configure_metrics(resolved_settings.observability)
application = FastAPI(title="G2S Aggregator", version="0.1.0") application = FastAPI(title="G2S Aggregator", version="0.1.0")
application.state.settings = resolved_settings application.state.settings = resolved_settings
+52 -2
View File
@@ -7,10 +7,14 @@ import json
from typing import Any, Callable, Protocol from typing import Any, Callable, Protocol
from pydantic import BaseModel from pydantic import BaseModel
import structlog
from app.config import RepositoryConfig from app.config import RepositoryConfig
logger = structlog.get_logger(__name__)
class RedisClientProtocol(Protocol): class RedisClientProtocol(Protocol):
async def get(self, key: str) -> bytes | str | None: ... async def get(self, key: str) -> bytes | str | None: ...
@@ -19,6 +23,14 @@ class RedisClientProtocol(Protocol):
async def delete(self, key: str) -> Any: ... async def delete(self, key: str) -> Any: ...
class CacheMetricsProtocol(Protocol):
def record_request(self, *, operation: str, outcome: str) -> None: ...
def record_error(self, *, operation: str, error_type: str) -> None: ...
def record_hit(self, *, hit: bool) -> None: ...
class PriceCacheRepositoryError(RuntimeError): class PriceCacheRepositoryError(RuntimeError):
"""Deterministic repository error raised on Redis operation failures.""" """Deterministic repository error raised on Redis operation failures."""
@@ -26,9 +38,16 @@ class PriceCacheRepositoryError(RuntimeError):
class PriceCache: class PriceCache:
"""Repository for cached provider payloads.""" """Repository for cached provider payloads."""
def __init__(self, redis_client: RedisClientProtocol, *, ttl_seconds: int) -> None: def __init__(
self,
redis_client: RedisClientProtocol,
*,
ttl_seconds: int,
metrics: CacheMetricsProtocol | None = None,
) -> None:
self._redis_client = redis_client self._redis_client = redis_client
self._ttl_seconds = ttl_seconds self._ttl_seconds = ttl_seconds
self._metrics = metrics
@classmethod @classmethod
def from_repository_config( def from_repository_config(
@@ -36,20 +55,25 @@ class PriceCache:
repository_config: RepositoryConfig, repository_config: RepositoryConfig,
*, *,
client_factory: Callable[[str], RedisClientProtocol] | None = None, client_factory: Callable[[str], RedisClientProtocol] | None = None,
metrics: CacheMetricsProtocol | None = None,
) -> "PriceCache": ) -> "PriceCache":
resolved_factory = client_factory or _default_redis_client_factory resolved_factory = client_factory or _default_redis_client_factory
redis_client = resolved_factory(repository_config.redis_dsn) redis_client = resolved_factory(repository_config.redis_dsn)
return cls( return cls(
redis_client=redis_client, redis_client=redis_client,
ttl_seconds=repository_config.price_cache_ttl_seconds, ttl_seconds=repository_config.price_cache_ttl_seconds,
metrics=metrics,
) )
async def get(self, key: str) -> Any | None: async def get(self, key: str) -> Any | None:
try: try:
payload = await self._redis_client.get(key) payload = await self._redis_client.get(key)
except Exception as exc: except Exception as exc:
self._record_failure(operation="get", error=exc)
raise PriceCacheRepositoryError("Redis cache read failed.") from exc raise PriceCacheRepositoryError("Redis cache read failed.") from exc
if payload is None: if payload is None:
self._record_request(operation="get", outcome="miss")
self._record_hit(hit=False)
return None return None
if isinstance(payload, bytes): if isinstance(payload, bytes):
@@ -59,7 +83,10 @@ class PriceCache:
else: else:
raise TypeError("Redis cache payload must be bytes or str.") raise TypeError("Redis cache payload must be bytes or str.")
return json.loads(serialized_payload) result = json.loads(serialized_payload)
self._record_request(operation="get", outcome="hit")
self._record_hit(hit=True)
return result
async def set(self, key: str, value: Any, ttl: int | None = None) -> None: async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
serialized_payload = _serialize(value) serialized_payload = _serialize(value)
@@ -67,13 +94,36 @@ class PriceCache:
try: try:
await self._redis_client.set(key, serialized_payload, ex=resolved_ttl) await self._redis_client.set(key, serialized_payload, ex=resolved_ttl)
except Exception as exc: except Exception as exc:
self._record_failure(operation="set", error=exc)
raise PriceCacheRepositoryError("Redis cache write failed.") from exc raise PriceCacheRepositoryError("Redis cache write failed.") from exc
self._record_request(operation="set", outcome="success")
async def invalidate(self, key: str) -> None: async def invalidate(self, key: str) -> None:
try: try:
await self._redis_client.delete(key) await self._redis_client.delete(key)
except Exception as exc: except Exception as exc:
self._record_failure(operation="invalidate", error=exc)
raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc
self._record_request(operation="invalidate", outcome="success")
def _record_failure(self, *, operation: str, error: Exception) -> None:
error_type = type(error).__name__
logger.warning(
"cache.operation_failed",
operation=operation,
error_type=error_type,
)
if self._metrics is not None:
self._metrics.record_request(operation=operation, outcome="error")
self._metrics.record_error(operation=operation, error_type=error_type)
def _record_request(self, *, operation: str, outcome: str) -> None:
if self._metrics is not None:
self._metrics.record_request(operation=operation, outcome=outcome)
def _record_hit(self, *, hit: bool) -> None:
if self._metrics is not None:
self._metrics.record_hit(hit=hit)
def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol: def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol:
+77
View File
@@ -0,0 +1,77 @@
"""OpenTelemetry metrics bootstrap and application instruments."""
from opentelemetry import metrics
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from app.config import ObservabilityConfig
_METER_PROVIDER: MeterProvider | None = None
_CACHE_METRICS: "CacheMetrics | None" = None
class CacheMetrics:
"""Metrics emitted by the Redis-backed price cache."""
def __init__(self) -> None:
meter = metrics.get_meter("app.repositories.cache")
self._requests = meter.create_counter(
"cache_requests_total",
unit="{request}",
description="Number of cache operations by operation and outcome.",
)
self._errors = meter.create_counter(
"cache_errors_total",
unit="{error}",
description="Number of failed cache operations by error type.",
)
self._hit_ratio = meter.create_histogram(
"cache_hit_ratio",
unit="1",
description="Cache lookup result: 1 for a hit and 0 for a miss.",
)
def record_request(self, *, operation: str, outcome: str) -> None:
self._requests.add(1, {"operation": operation, "outcome": outcome})
def record_error(self, *, operation: str, error_type: str) -> None:
self._errors.add(1, {"operation": operation, "error.type": error_type})
def record_hit(self, *, hit: bool) -> None:
self._hit_ratio.record(1.0 if hit else 0.0)
def configure_metrics(observability: ObservabilityConfig) -> None:
"""Configure the process-wide OTLP metrics exporter once."""
global _METER_PROVIDER
if not observability.enabled or _METER_PROVIDER is not None:
return
meter_provider = _build_meter_provider(observability)
metrics.set_meter_provider(meter_provider)
_METER_PROVIDER = meter_provider
def get_cache_metrics() -> CacheMetrics:
"""Return the process-wide cache instruments."""
global _CACHE_METRICS
if _CACHE_METRICS is None:
_CACHE_METRICS = CacheMetrics()
return _CACHE_METRICS
def _build_meter_provider(observability: ObservabilityConfig) -> MeterProvider:
exporter = OTLPMetricExporter(
endpoint=observability.otlp_endpoint,
insecure=observability.otlp_insecure,
)
reader = PeriodicExportingMetricReader(exporter)
resource = Resource.create({"service.name": observability.service_name})
return MeterProvider(resource=resource, metric_readers=[reader])
+1 -1
View File
@@ -11,7 +11,7 @@ business_logic:
provider_price_multiplier: 1.0 provider_price_multiplier: 1.0
repository: repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://redis:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: adapter:
+1 -1
View File
@@ -104,6 +104,6 @@ service:
processors: [resource/env, resource/version] processors: [resource/env, resource/version]
exporters: [otlp] exporters: [otlp]
metrics: metrics:
receivers: [docker_stats] receivers: [otlp, docker_stats]
processors: [filter/environment, resource/env] processors: [filter/environment, resource/env]
exporters: [otlp] exporters: [otlp]
@@ -122,8 +122,11 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
class StubPriceCache: class StubPriceCache:
@classmethod @classmethod
def from_repository_config(cls, repository_config) -> StubCache: def from_repository_config(
cls, repository_config, *, metrics
) -> StubCache:
_ = repository_config _ = repository_config
assert metrics is delivery_controller.get_cache_metrics()
return StubCache() return StubCache()
stub_http_client = StubHttpClient() stub_http_client = StubHttpClient()
+4 -1
View File
@@ -138,8 +138,11 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
class StubPriceCache: class StubPriceCache:
@classmethod @classmethod
def from_repository_config(cls, repository_config) -> StubCache: def from_repository_config(
cls, repository_config, *, metrics
) -> StubCache:
_ = repository_config _ = repository_config
assert metrics is delivery_controller.get_cache_metrics()
return stub_cache return stub_cache
stub_http_client = StubHttpClient() stub_http_client = StubHttpClient()
+121
View File
@@ -0,0 +1,121 @@
import importlib
from app.config import ObservabilityConfig
def _reload_metrics_module():
metrics_module = importlib.import_module("app.runtime.metrics")
return importlib.reload(metrics_module)
def _make_observability_config(**overrides) -> ObservabilityConfig:
payload = {
"enabled": True,
"service_name": "g2s-aggregator",
"otlp_endpoint": "http://collector:4317",
"otlp_insecure": True,
}
payload.update(overrides)
return ObservabilityConfig(**payload)
def test_configure_metrics_skips_bootstrap_when_disabled(monkeypatch) -> None:
metrics_module = _reload_metrics_module()
calls: list[object] = []
monkeypatch.setattr(
metrics_module,
"_build_meter_provider",
lambda observability: calls.append(observability),
)
metrics_module.configure_metrics(_make_observability_config(enabled=False))
assert calls == []
def test_build_meter_provider_uses_otlp_exporter_and_service_name(monkeypatch) -> None:
metrics_module = _reload_metrics_module()
created: dict[str, object] = {}
class FakeExporter:
def __init__(self, *, endpoint: str, insecure: bool) -> None:
created["endpoint"] = endpoint
created["insecure"] = insecure
class FakeReader:
def __init__(self, exporter: object) -> None:
created["exporter"] = exporter
class FakeMeterProvider:
def __init__(self, *, resource, metric_readers: list[object]) -> None:
self.resource = resource
self.metric_readers = metric_readers
monkeypatch.setattr(metrics_module, "OTLPMetricExporter", FakeExporter)
monkeypatch.setattr(metrics_module, "PeriodicExportingMetricReader", FakeReader)
monkeypatch.setattr(metrics_module, "MeterProvider", FakeMeterProvider)
provider = metrics_module._build_meter_provider(
_make_observability_config(
otlp_endpoint="http://metrics-backend:4317",
otlp_insecure=False,
)
)
assert created["endpoint"] == "http://metrics-backend:4317"
assert created["insecure"] is False
assert provider.resource.attributes["service.name"] == "g2s-aggregator"
assert len(provider.metric_readers) == 1
assert created["exporter"] is not None
def test_cache_metrics_create_and_record_expected_instruments(monkeypatch) -> None:
metrics_module = _reload_metrics_module()
created: dict[str, object] = {}
class FakeInstrument:
def __init__(self, name: str) -> None:
self.name = name
self.records: list[tuple[float, dict[str, str] | None]] = []
def add(self, value: float, attributes: dict[str, str]) -> None:
self.records.append((value, attributes))
def record(
self, value: float, attributes: dict[str, str] | None = None
) -> None:
self.records.append((value, attributes))
class FakeMeter:
def create_counter(self, name: str, **kwargs) -> FakeInstrument:
del kwargs
instrument = FakeInstrument(name)
created[name] = instrument
return instrument
def create_histogram(self, name: str, **kwargs) -> FakeInstrument:
del kwargs
instrument = FakeInstrument(name)
created[name] = instrument
return instrument
monkeypatch.setattr(metrics_module.metrics, "get_meter", lambda _name: FakeMeter())
cache_metrics = metrics_module.CacheMetrics()
cache_metrics.record_request(operation="get", outcome="hit")
cache_metrics.record_error(operation="set", error_type="ConnectionError")
cache_metrics.record_hit(hit=True)
cache_metrics.record_hit(hit=False)
assert set(created) == {
"cache_requests_total",
"cache_errors_total",
"cache_hit_ratio",
}
assert created["cache_requests_total"].records == [
(1, {"operation": "get", "outcome": "hit"})
]
assert created["cache_errors_total"].records == [
(1, {"operation": "set", "error.type": "ConnectionError"})
]
assert created["cache_hit_ratio"].records == [(1.0, None), (0.0, None)]
+67
View File
@@ -69,6 +69,22 @@ class FailingSetRedisClient:
return 1 return 1
class RecordingCacheMetrics:
def __init__(self) -> None:
self.requests: list[tuple[str, str]] = []
self.errors: list[tuple[str, str]] = []
self.hits: list[bool] = []
def record_request(self, *, operation: str, outcome: str) -> None:
self.requests.append((operation, outcome))
def record_error(self, *, operation: str, error_type: str) -> None:
self.errors.append((operation, error_type))
def record_hit(self, *, hit: bool) -> None:
self.hits.append(hit)
def test_cache_miss_returns_none() -> None: def test_cache_miss_returns_none() -> None:
cache = PriceCache(InMemoryRedisClient(), ttl_seconds=120) cache = PriceCache(InMemoryRedisClient(), ttl_seconds=120)
@@ -263,3 +279,54 @@ def test_set_raises_deterministic_repository_error_on_redis_failure() -> None:
with pytest.raises(PriceCacheRepositoryError, match="Redis cache write failed."): with pytest.raises(PriceCacheRepositoryError, match="Redis cache write failed."):
asyncio.run(cache.set("key", {"v": 1})) asyncio.run(cache.set("key", {"v": 1}))
def test_cache_metrics_record_hits_misses_and_successful_writes() -> None:
redis_client = InMemoryRedisClient()
metrics = RecordingCacheMetrics()
cache = PriceCache(redis_client, ttl_seconds=10, metrics=metrics)
async def scenario() -> None:
await cache.get("missing")
await cache.set("key", {"v": 1})
await cache.get("key")
await cache.invalidate("key")
asyncio.run(scenario())
assert metrics.requests == [
("get", "miss"),
("set", "success"),
("get", "hit"),
("invalidate", "success"),
]
assert metrics.errors == []
assert metrics.hits == [False, True]
def test_cache_failure_records_metrics_and_structured_log(monkeypatch) -> None:
events: list[tuple[str, dict[str, object]]] = []
metrics = RecordingCacheMetrics()
cache = PriceCache(FailingGetRedisClient(), ttl_seconds=10, metrics=metrics)
class StubLogger:
def warning(self, event: str, **context: object) -> None:
events.append((event, context))
monkeypatch.setattr(
"app.repositories.cache.redis_cache.logger",
StubLogger(),
)
with pytest.raises(PriceCacheRepositoryError, match="Redis cache read failed."):
asyncio.run(cache.get("key"))
assert metrics.requests == [("get", "error")]
assert metrics.errors == [("get", "RuntimeError")]
assert metrics.hits == []
assert events == [
(
"cache.operation_failed",
{"operation": "get", "error_type": "RuntimeError"},
)
]
+16
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 metrics as metrics_module
from app.runtime import tracing as tracing_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"
@@ -21,6 +22,7 @@ def _import_main_module(monkeypatch):
def test_app_import_smoke(monkeypatch) -> None: def test_app_import_smoke(monkeypatch) -> None:
logging_bootstrap_calls: list[None] = [] logging_bootstrap_calls: list[None] = []
metrics_bootstrap_calls: list[object] = []
tracing_bootstrap_calls: list[tuple[FastAPI, object]] = [] tracing_bootstrap_calls: list[tuple[FastAPI, object]] = []
def fake_configure_logging() -> None: def fake_configure_logging() -> None:
@@ -29,7 +31,11 @@ def test_app_import_smoke(monkeypatch) -> None:
def fake_configure_tracing(app: FastAPI, observability: object) -> None: def fake_configure_tracing(app: FastAPI, observability: object) -> None:
tracing_bootstrap_calls.append((app, observability)) tracing_bootstrap_calls.append((app, observability))
def fake_configure_metrics(observability: object) -> None:
metrics_bootstrap_calls.append(observability)
monkeypatch.setattr(logging_module, "configure_logging", fake_configure_logging) monkeypatch.setattr(logging_module, "configure_logging", fake_configure_logging)
monkeypatch.setattr(metrics_module, "configure_metrics", fake_configure_metrics)
monkeypatch.setattr(tracing_module, "configure_tracing", fake_configure_tracing) monkeypatch.setattr(tracing_module, "configure_tracing", fake_configure_tracing)
main_module = _import_main_module(monkeypatch) main_module = _import_main_module(monkeypatch)
@@ -39,6 +45,7 @@ def test_app_import_smoke(monkeypatch) -> None:
route.path for route in main_module.app.routes route.path for route in main_module.app.routes
} }
assert logging_bootstrap_calls == [None] assert logging_bootstrap_calls == [None]
assert metrics_bootstrap_calls == [main_module.app.state.settings.observability]
assert tracing_bootstrap_calls == [ assert tracing_bootstrap_calls == [
(main_module.app, main_module.app.state.settings.observability) (main_module.app, main_module.app.state.settings.observability)
] ]
@@ -47,6 +54,7 @@ def test_app_import_smoke(monkeypatch) -> None:
def test_app_created_with_provided_settings(monkeypatch) -> None: def test_app_created_with_provided_settings(monkeypatch) -> None:
logging_bootstrap_calls: list[None] = [] logging_bootstrap_calls: list[None] = []
metrics_bootstrap_calls: list[object] = []
tracing_bootstrap_calls: list[tuple[FastAPI, object]] = [] tracing_bootstrap_calls: list[tuple[FastAPI, object]] = []
def fake_configure_logging() -> None: def fake_configure_logging() -> None:
@@ -55,7 +63,11 @@ def test_app_created_with_provided_settings(monkeypatch) -> None:
def fake_configure_tracing(app: FastAPI, observability: object) -> None: def fake_configure_tracing(app: FastAPI, observability: object) -> None:
tracing_bootstrap_calls.append((app, observability)) tracing_bootstrap_calls.append((app, observability))
def fake_configure_metrics(observability: object) -> None:
metrics_bootstrap_calls.append(observability)
monkeypatch.setattr(logging_module, "configure_logging", fake_configure_logging) monkeypatch.setattr(logging_module, "configure_logging", fake_configure_logging)
monkeypatch.setattr(metrics_module, "configure_metrics", fake_configure_metrics)
monkeypatch.setattr(tracing_module, "configure_tracing", fake_configure_tracing) 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))
@@ -65,6 +77,10 @@ def test_app_created_with_provided_settings(monkeypatch) -> None:
assert instance.state.settings is settings assert instance.state.settings is settings
assert logging_bootstrap_calls == [None, None] assert logging_bootstrap_calls == [None, None]
assert metrics_bootstrap_calls == [
main_module.app.state.settings.observability,
settings.observability,
]
assert tracing_bootstrap_calls == [ assert tracing_bootstrap_calls == [
(main_module.app, main_module.app.state.settings.observability), (main_module.app, main_module.app.state.settings.observability),
(instance, settings.observability), (instance, settings.observability),