пофикшен 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
@@ -122,8 +122,11 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
class StubPriceCache:
@classmethod
def from_repository_config(cls, repository_config) -> StubCache:
def from_repository_config(
cls, repository_config, *, metrics
) -> StubCache:
_ = repository_config
assert metrics is delivery_controller.get_cache_metrics()
return StubCache()
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:
@classmethod
def from_repository_config(cls, repository_config) -> StubCache:
def from_repository_config(
cls, repository_config, *, metrics
) -> StubCache:
_ = repository_config
assert metrics is delivery_controller.get_cache_metrics()
return stub_cache
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
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:
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."):
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.config import Settings, get_settings
from app.runtime import logging as logging_module
from app.runtime import metrics as metrics_module
from app.runtime import tracing as tracing_module
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:
logging_bootstrap_calls: list[None] = []
metrics_bootstrap_calls: list[object] = []
tracing_bootstrap_calls: list[tuple[FastAPI, object]] = []
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:
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(metrics_module, "configure_metrics", fake_configure_metrics)
monkeypatch.setattr(tracing_module, "configure_tracing", fake_configure_tracing)
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
}
assert logging_bootstrap_calls == [None]
assert metrics_bootstrap_calls == [main_module.app.state.settings.observability]
assert tracing_bootstrap_calls == [
(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:
logging_bootstrap_calls: list[None] = []
metrics_bootstrap_calls: list[object] = []
tracing_bootstrap_calls: list[tuple[FastAPI, object]] = []
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:
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(metrics_module, "configure_metrics", fake_configure_metrics)
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))
@@ -65,6 +77,10 @@ def test_app_created_with_provided_settings(monkeypatch) -> None:
assert instance.state.settings is settings
assert logging_bootstrap_calls == [None, None]
assert metrics_bootstrap_calls == [
main_module.app.state.settings.observability,
settings.observability,
]
assert tracing_bootstrap_calls == [
(main_module.app, main_module.app.state.settings.observability),
(instance, settings.observability),