"""Пометка «этот запрос пришёл из публичной формы» и её единственное следствие: адрес, который ввёл аноним, не попадает в журналы. ЗАЧЕМ ЭТО СУЩЕСТВУЕТ -------------------- На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что введённый адрес нигде не сохраняется. По базам данных это правда (обе публичные ручки только читают), а по журналам — не было: геокодер логирует запрос открытым текстом на каждый вызов, например INFO app.services.dadata: dadata suggest: 'онуфриева 24' → 5 вариантов Прод пишет stdout контейнеров в journald с persistent-хранилищем (`tradein-mvp/docker-compose.prod.yml`), то есть строка ложится на диск и живёт там неделями. Рядом, в access-логе Caddy, лежит IP того же запроса с той же меткой времени — то есть адрес квартиры фактически сохранён и сопоставим с человеком. Ровно то, что публичная страница обещает не делать. Найдено состязательным ревью PR публичного периметра (16.08.2026) и воспроизведено на проде, а не выведено из чтения кода. ПОЧЕМУ ФИЛЬТР, А НЕ ПРАВКА КАЖДОГО ВЫЗОВА logger ------------------------------------------------ Мест, где адрес попадает в лог, много (`app/services/dadata.py`, `app/services/geocoder.py` — успех, пустая выдача, сетевая ошибка, таймаут, кадастровый фолбэк), и любое новое добавится незаметно. Обещание не должно зависеть от того, вспомнил ли автор следующей правки про эту страницу. Фильтр — единственная точка, которая закрывает и уже написанное, и будущее. ПОЧЕМУ contextvar ----------------- Публичный и закрытый контуры обслуживает ОДИН процесс, и один и тот же `suggest()` вызывают оба. Различить их можно только по текущему запросу. `ContextVar` — то, что переживает `await` и копируется в `asyncio.to_thread` (им геокодер уходит в синхронный кадастровый тир), в отличие от глобального флага, который в конкурентной обработке принадлежал бы соседнему запросу. Для B2B-трафика ничего не меняется: там флаг не выставлен, логи прежние — они нужны, чтобы разбирать жалобы пилотов на подсказки. """ from __future__ import annotations import logging from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar #: Истинно, пока обрабатывается запрос анонимной публичной формы. is_public_request: ContextVar[bool] = ContextVar("mera_is_public_request", default=False) #: Что видно в журнале вместо сообщения. Уровень и логгер сохраняются — по ним #: по-прежнему видно, что вызов был и чем закончился. REDACTED_MESSAGE = "<публичный запрос МЕРЫ: содержимое скрыто>" #: Логгеры, чьи записи могут содержать введённый адрес. ADDRESS_LOGGERS = ("app.services.dadata", "app.services.geocoder") @contextmanager def public_request_scope() -> Iterator[None]: """Помечает текущий запрос публичным на время работы блока.""" token = is_public_request.set(True) try: yield finally: is_public_request.reset(token) class RedactPublicAddressFilter(logging.Filter): """Заменяет сообщение целиком, пока обрабатывается публичный запрос. Целиком, а не по ключам: в шаблонах сообщений адрес стоит рядом с безобидными аргументами (`"%r → %d вариантов"`), и отличить их друг от друга внутри фильтра нельзя. Терять текст сообщения на публичном пути дешевле, чем хранить адреса; на закрытом контуре текст остаётся полным. """ def filter(self, record: logging.LogRecord) -> bool: if is_public_request.get(): record.msg = REDACTED_MESSAGE record.args = () return True def install_address_log_redaction() -> None: """Вешает фильтр на логгеры, видящие адрес. Идемпотентно. Фильтр ставится на КОНКРЕТНЫЕ логгеры, а не на корневой хендлер: фильтры логгера применяются к записям этого логгера, а не ко всему, что через хендлер проходит, — то есть посторонние сообщения (пул соединений, старт приложения) во время публичного запроса не пострадают. """ for name in ADDRESS_LOGGERS: logger = logging.getLogger(name) if not any(isinstance(f, RedactPublicAddressFilter) for f in logger.filters): logger.addFilter(RedactPublicAddressFilter())