feat(metrics): метрики доступности БД и занятости пулов БД/Redis
vidconf_db_up проверяется отдельным от основного пула соединением (NullPool, короткий таймаут) — иначе в момент исчерпания пула проверка сама встала бы в очередь и не отличила бы «БД лежит» от «пул занят». vidconf_db_pool_* читаются синхронно из engine.pool, без единого запроса к БД. metrics_endpoint больше не виснет и не падает при недоступном основном пуле: критичные gauge'и считаются первыми и не зависят от него, а vidconf_pipeline_sessions (по-прежнему через Depends(get_session) — тестовый харнесс подменяет её на savepoint-сессию) обёрнут таймаутом и try/except.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей и железа.
|
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей, БД и железа.
|
||||||
|
|
||||||
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
|
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
|
||||||
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
|
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
|
||||||
@@ -12,8 +12,22 @@ Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth`/
|
|||||||
Redis) можно опросить обычным `await` вместо реализации синхронного
|
Redis) можно опросить обычным `await` вместо реализации синхронного
|
||||||
`prometheus_client.registry.Collector` (у `vidconf_host_info` источник
|
`prometheus_client.registry.Collector` (у `vidconf_host_info` источник
|
||||||
и вовсе синхронный — настройки уже в памяти процесса).
|
и вовсе синхронный — настройки уже в памяти процесса).
|
||||||
|
|
||||||
|
🔴 Метрики о состоянии основного пула БД (`vidconf_db_up`,
|
||||||
|
`vidconf_db_pool_*`) обязаны читаться БЕЗ обращения к самому пулу — иначе
|
||||||
|
в момент его исчерпания (см. `.forcc/session-results/32-loadtest-07-08-debug.md`)
|
||||||
|
эндпоинт метрик падал бы вместе со всем остальным ровно тогда, когда нужнее
|
||||||
|
всего. `vidconf_db_pool_*` — синхронный снимок `engine.pool` (см.
|
||||||
|
`core/db.py::db_pool_stats`), `vidconf_db_up` — отдельное соединение вне
|
||||||
|
основного пула (`core/db.py::check_db_up`). `_refresh_pipeline_sessions_gauge`
|
||||||
|
по-прежнему ходит через основной пул (`Depends(get_session)`, тестовый
|
||||||
|
харнесс подменяет её на savepoint-сессию — см. `tests/conftest.py`; развести
|
||||||
|
полностью, как `vidconf_db_up`, значило бы переделывать харнесс ради того же
|
||||||
|
эффекта — цена не оправдана, см. прецедент `f7c4fb4`/session 32), но обёрнута
|
||||||
|
таймаутом и try/except, чтобы её недоступность не роняла остальные метрики.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
@@ -23,7 +37,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from starlette.routing import Match
|
from starlette.routing import Match
|
||||||
|
|
||||||
from core.config import get_settings
|
from core.config import get_settings
|
||||||
from core.db import get_session
|
from core.db import check_db_up, db_pool_checked_out, get_session
|
||||||
from core.redis import redis_client
|
from core.redis import redis_client
|
||||||
from models.session import PIPELINE_STATUSES
|
from models.session import PIPELINE_STATUSES
|
||||||
from repositories.conferences import ConferenceSessionRepository
|
from repositories.conferences import ConferenceSessionRepository
|
||||||
@@ -80,9 +94,30 @@ PIPELINE_SESSIONS = Gauge(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Сколько ждать основной пул под этой конкретной метрикой, прежде чем
|
||||||
|
# сдаться и оставить прежнее значение gauge. Меньше `db_pool_timeout` (10с,
|
||||||
|
# `core/config.py`) — Prometheus скрейпит раз в 15с, и эта метрика не должна
|
||||||
|
# в одиночку съедать бюджет всего окна scrape.
|
||||||
|
_PIPELINE_GAUGE_TIMEOUT_S = 2.0
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
|
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
|
||||||
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`."""
|
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`.
|
||||||
counts = await ConferenceSessionRepository(session).count_by_pipeline_status()
|
|
||||||
|
Ходит через основной пул (`session` — из `Depends(get_session)`, см.
|
||||||
|
докстринг модуля про ограничения тестового харнесса). Если пул занят
|
||||||
|
или БД недоступна, запрос не должен держать весь `/metrics` — таймаут
|
||||||
|
короче `db_pool_timeout`, ошибка гасится, gauge остаётся на прежнем
|
||||||
|
значении (не обнуляется — обнулять его при недоступности БД так же
|
||||||
|
неверно, как считать сеансы пропавшими).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
counts = await asyncio.wait_for(
|
||||||
|
ConferenceSessionRepository(session).count_by_pipeline_status(),
|
||||||
|
timeout=_PIPELINE_GAUGE_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return
|
||||||
for status in PIPELINE_STATUSES:
|
for status in PIPELINE_STATUSES:
|
||||||
PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0))
|
PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0))
|
||||||
|
|
||||||
@@ -113,6 +148,70 @@ async def _refresh_celery_queue_depth_gauge() -> None:
|
|||||||
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
|
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Доступность БД и занятость основного пула (сессия 33) -----------------
|
||||||
|
#
|
||||||
|
# Ранний сигнал важнее самого факта отказа: в инциденте 07.08 пул заполнялся
|
||||||
|
# постепенно (`idle in transaction` 3→8→16→26→35→39→40 участников) —
|
||||||
|
# `vidconf_db_pool_checked_out` показал бы это задолго до первого 500.
|
||||||
|
# Обе метрики читаются без обращения к основному пулу (см. докстринг модуля
|
||||||
|
# и `core/db.py`), поэтому доступны и в момент, когда сам пул исчерпан.
|
||||||
|
|
||||||
|
DB_UP = Gauge(
|
||||||
|
"vidconf_db_up",
|
||||||
|
"Доступность БД (1/0) — проверяется отдельным соединением вне основного пула",
|
||||||
|
)
|
||||||
|
|
||||||
|
DB_POOL_SIZE = Gauge(
|
||||||
|
"vidconf_db_pool_size",
|
||||||
|
"Настроенный размер основного пула БД без overflow (db_pool_size)",
|
||||||
|
)
|
||||||
|
DB_POOL_MAX_OVERFLOW = Gauge(
|
||||||
|
"vidconf_db_pool_max_overflow",
|
||||||
|
"Настроенный максимум overflow-соединений сверх db_pool_size (db_max_overflow)",
|
||||||
|
)
|
||||||
|
DB_POOL_CHECKED_OUT = Gauge(
|
||||||
|
"vidconf_db_pool_checked_out",
|
||||||
|
"Число соединений основного пула БД, занятых прямо сейчас (в пуле + overflow)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_db_up_gauge() -> None:
|
||||||
|
"""Пересчитать `vidconf_db_up` отдельным от основного пула соединением."""
|
||||||
|
DB_UP.set(1 if await check_db_up() else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_db_pool_gauges() -> None:
|
||||||
|
"""Пересчитать gauge'и занятости основного пула — синхронно, без I/O."""
|
||||||
|
settings = get_settings()
|
||||||
|
DB_POOL_SIZE.set(settings.db_pool_size)
|
||||||
|
DB_POOL_MAX_OVERFLOW.set(settings.db_max_overflow)
|
||||||
|
DB_POOL_CHECKED_OUT.set(db_pool_checked_out())
|
||||||
|
|
||||||
|
|
||||||
|
# --- Занятость пула Redis (сессия 33, второй потолок из session 32) --------
|
||||||
|
#
|
||||||
|
# Тот же класс отказа, что и у пула БД: каждое WS-подключение комнаты держит
|
||||||
|
# pub/sub-соединение всё время, пока участник в конференции (`core/redis.py`,
|
||||||
|
# `redis_max_connections`). Снимок — синхронный (атрибуты пула в памяти
|
||||||
|
# процесса redis-py), Redis для этого спрашивать не нужно.
|
||||||
|
|
||||||
|
REDIS_POOL_IN_USE = Gauge(
|
||||||
|
"vidconf_redis_pool_in_use",
|
||||||
|
"Число занятых соединений пула Redis прямо сейчас",
|
||||||
|
)
|
||||||
|
REDIS_POOL_MAX = Gauge(
|
||||||
|
"vidconf_redis_pool_max_connections",
|
||||||
|
"Настроенный максимум соединений пула Redis (redis_max_connections)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_redis_pool_gauges() -> None:
|
||||||
|
"""Пересчитать gauge'и занятости пула Redis — синхронно, без I/O."""
|
||||||
|
pool = redis_client.connection_pool
|
||||||
|
REDIS_POOL_IN_USE.set(len(pool._in_use_connections)) # noqa: SLF001
|
||||||
|
REDIS_POOL_MAX.set(pool.max_connections)
|
||||||
|
|
||||||
|
|
||||||
# --- Info-метрика обнаруженного железа (install.sh, ADR-004) ---------------
|
# --- Info-метрика обнаруженного железа (install.sh, ADR-004) ---------------
|
||||||
|
|
||||||
HOST_INFO = Gauge(
|
HOST_INFO = Gauge(
|
||||||
@@ -158,7 +257,16 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
|
|||||||
ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на
|
ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на
|
||||||
каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит
|
каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит
|
||||||
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
||||||
|
|
||||||
|
Порядок важен: метрики о состоянии основного пула БД (`_refresh_db_up_gauge`,
|
||||||
|
`_refresh_db_pool_gauges`) считаются первыми и не зависят от самого пула
|
||||||
|
(см. докстринг модуля) — они гарантированно попадут в ответ, даже если
|
||||||
|
следующий за ними `_refresh_pipeline_sessions_gauge` (основной пул) зависнет
|
||||||
|
или упадёт под нагрузкой.
|
||||||
"""
|
"""
|
||||||
|
await _refresh_db_up_gauge()
|
||||||
|
_refresh_db_pool_gauges()
|
||||||
|
_refresh_redis_pool_gauges()
|
||||||
await _refresh_pipeline_sessions_gauge(session)
|
await _refresh_pipeline_sessions_gauge(session)
|
||||||
await _refresh_celery_queue_depth_gauge()
|
await _refresh_celery_queue_depth_gauge()
|
||||||
_refresh_host_info_gauge()
|
_refresh_host_info_gauge()
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ class Settings(BaseSettings):
|
|||||||
# и показывает проблему, а не висит полминуты, делая вид, что всё живо.
|
# и показывает проблему, а не висит полминуты, делая вид, что всё живо.
|
||||||
db_pool_timeout: int = 10
|
db_pool_timeout: int = 10
|
||||||
|
|
||||||
|
# --- Проверка доступности БД вне основного пула (`core/db.py::check_db_up`) ---
|
||||||
|
# Таймаут TCP/auth отдельного соединения-пробы (не путать с
|
||||||
|
# `db_pool_timeout` выше — тот про очередь на основной пул). Дефолт
|
||||||
|
# asyncpg — 60с, для сигнала мониторинга это неприемлемо долго: пусть
|
||||||
|
# `vidconf_db_up` станет 0 за секунды, а не через минуту.
|
||||||
|
db_probe_timeout_s: float = 3.0
|
||||||
|
|
||||||
# --- Пул соединений с Redis ---
|
# --- Пул соединений с Redis ---
|
||||||
# Считается по УЧАСТНИКАМ, а не по запросам: каждое WS-подключение комнаты
|
# Считается по УЧАСТНИКАМ, а не по запросам: каждое WS-подключение комнаты
|
||||||
# (`api/chat.py`) держит собственное pub/sub-соединение всё время, пока
|
# (`api/chat.py`) держит собственное pub/sub-соединение всё время, пока
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
"""Настройка асинхронного движка SQLAlchemy и сеанса."""
|
"""Настройка асинхронного движка SQLAlchemy и сеанса."""
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncEngine,
|
AsyncEngine,
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
create_async_engine,
|
create_async_engine,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.pool import NullPool, QueuePool
|
||||||
|
|
||||||
from core.config import get_settings
|
from core.config import get_settings
|
||||||
|
|
||||||
@@ -31,3 +34,45 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
"""Зависимость FastAPI, возвращающая `AsyncSession`."""
|
"""Зависимость FastAPI, возвращающая `AsyncSession`."""
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
# --- Проверка доступности БД вне основного пула (сессия 33) ----------------
|
||||||
|
#
|
||||||
|
# Отдельный движок с `NullPool`: каждый вызов открывает новое соединение и
|
||||||
|
# закрывает его сразу после — бюджет соединений не пересекается с
|
||||||
|
# `engine.pool` (10 + 10 overflow × число воркеров uvicorn). Это единственный
|
||||||
|
# способ отличить «БД лежит» от «основной пул занят под нагрузкой»: проверка
|
||||||
|
# через `get_session()` в момент исчерпания пула сама встала бы в очередь на
|
||||||
|
# `db_pool_timeout` и не смогла бы ответить, пока не появится случайно
|
||||||
|
# освободившееся место — то есть не отличила бы два принципиально разных
|
||||||
|
# состояния. Короткий `timeout` на соединение (не путать с `db_pool_timeout`
|
||||||
|
# основного пула) — чтобы зависший, а не оборванный TCP (Postgres отвечает,
|
||||||
|
# но не может продвинуться) не держал проверку до дефолтных 60 секунд asyncpg.
|
||||||
|
_probe_engine: AsyncEngine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
poolclass=NullPool,
|
||||||
|
connect_args={"timeout": settings.db_probe_timeout_s},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_db_up() -> bool:
|
||||||
|
"""`True`, если БД отвечает на `SELECT 1` по отдельному от основного пула соединению."""
|
||||||
|
try:
|
||||||
|
async with _probe_engine.connect() as connection:
|
||||||
|
await connection.execute(text("SELECT 1"))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def db_pool_checked_out() -> int:
|
||||||
|
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
|
||||||
|
|
||||||
|
SQLAlchemy держит счётчик в памяти самого объекта пула (`engine.pool`),
|
||||||
|
поэтому его можно прочитать в любой момент, даже когда все соединения
|
||||||
|
заняты или БД недоступна — именно это нужно алерту на исчерпание пула
|
||||||
|
(метрика не должна зависеть от того, что измеряет). Размер и лимит
|
||||||
|
overflow — конфигурация (`Settings.db_pool_size`/`db_max_overflow`),
|
||||||
|
их не нужно снимать с объекта пула отдельно.
|
||||||
|
"""
|
||||||
|
return cast(QueuePool, engine.pool).checkedout()
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ async def test_metrics_endpoint_returns_prometheus_exposition_format(
|
|||||||
assert "vidconf_pipeline_sessions" in families
|
assert "vidconf_pipeline_sessions" in families
|
||||||
assert "vidconf_celery_queue_depth" in families
|
assert "vidconf_celery_queue_depth" in families
|
||||||
assert "vidconf_host_info" in families
|
assert "vidconf_host_info" in families
|
||||||
|
assert "vidconf_db_up" in families
|
||||||
|
assert "vidconf_db_pool_size" in families
|
||||||
|
assert "vidconf_db_pool_max_overflow" in families
|
||||||
|
assert "vidconf_db_pool_checked_out" in families
|
||||||
|
assert "vidconf_redis_pool_in_use" in families
|
||||||
|
assert "vidconf_redis_pool_max_connections" in families
|
||||||
|
|
||||||
|
|
||||||
async def test_metrics_host_info_gauge_reflects_settings(
|
async def test_metrics_host_info_gauge_reflects_settings(
|
||||||
@@ -129,6 +135,72 @@ async def test_metrics_pipeline_sessions_gauge_reflects_new_session(
|
|||||||
assert after == before + 1
|
assert after == before + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metrics_db_up_gauge_reflects_real_connectivity(client: httpx.AsyncClient) -> None:
|
||||||
|
"""Против реального тестового Postgres (см. докстринг conftest) `vidconf_db_up` == 1."""
|
||||||
|
response = await client.get("/metrics")
|
||||||
|
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
|
||||||
|
assert value == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metrics_db_up_gauge_reports_down_without_crashing_endpoint(
|
||||||
|
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Недоступность БД (проверка вне пула не удалась) не роняет `/metrics` — отдаёт 0, не 500."""
|
||||||
|
|
||||||
|
async def _fail() -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(metrics_module, "check_db_up", _fail)
|
||||||
|
|
||||||
|
response = await client.get("/metrics")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
|
||||||
|
assert value == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metrics_db_pool_gauges_reflect_settings_not_usage(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""`vidconf_db_pool_size`/`_max_overflow` — конфигурация из `Settings`, не текущая занятость."""
|
||||||
|
settings = get_settings()
|
||||||
|
response = await client.get("/metrics")
|
||||||
|
|
||||||
|
samples_size = _samples(response.text, "vidconf_db_pool_size")
|
||||||
|
samples_overflow = _samples(response.text, "vidconf_db_pool_max_overflow")
|
||||||
|
size = _sample_value(samples_size, suffix="vidconf_db_pool_size")
|
||||||
|
max_overflow = _sample_value(samples_overflow, suffix="vidconf_db_pool_max_overflow")
|
||||||
|
|
||||||
|
assert size == settings.db_pool_size
|
||||||
|
assert max_overflow == settings.db_max_overflow
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metrics_endpoint_survives_pipeline_gauge_failure(
|
||||||
|
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Падение/таймаут основного пула на одном gauge не роняет весь `/metrics`.
|
||||||
|
|
||||||
|
Симулирует ровно ситуацию инцидента 07.08 (`api/metrics.py` падал вместе
|
||||||
|
со всем остальным при исчерпанном пуле): `count_by_pipeline_status`
|
||||||
|
поднимает исключение — `vidconf_db_up`/`vidconf_db_pool_*` (не зависящие
|
||||||
|
от основного пула) при этом всё равно приходят в ответе.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _raise(*args: object, **kwargs: object) -> dict[str, int]:
|
||||||
|
raise TimeoutError("основной пул занят (симуляция теста)")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"repositories.conferences.ConferenceSessionRepository.count_by_pipeline_status",
|
||||||
|
_raise,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.get("/metrics")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
db_up = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
|
||||||
|
assert db_up == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_metrics_celery_queue_depth_gauge(
|
async def test_metrics_celery_queue_depth_gauge(
|
||||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
# сервис `prometheus`).
|
# сервис `prometheus`).
|
||||||
#
|
#
|
||||||
# Имена метрик backend (`vidconf_http_request_duration_seconds`,
|
# Имена метрик backend (`vidconf_http_request_duration_seconds`,
|
||||||
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с
|
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`, `vidconf_db_up`,
|
||||||
|
# `vidconf_db_pool_*`, `vidconf_redis_pool_*`) — КОНТРАКТ с
|
||||||
# `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально —
|
# `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально —
|
||||||
# при переименовании метрик в backend поправить оба файла одновременно.
|
# при переименовании метрик в backend поправить оба файла одновременно.
|
||||||
global:
|
global:
|
||||||
|
|||||||
Reference in New Issue
Block a user