Среди всех ошибок безопасности утечка персональных данных стоит особняком: её последствия видны не сразу, но масштаб может быть огромным — штрафы регуляторов, потеря доверия пользователей, обязательное уведомление всех пострадавших. Разберём, где данные утекают чаще всего и как этого не допустить.
Проще всего это увидеть на одном значении: проследим, в скольких местах окажется email одного клиента, если ни на одном шаге его не остановить.
Одно поле расходится по каналам, из которых его уже не забрать: toString(), текст исключения в detail, поле внутри события. Заслон нужен на каждом — тогда в логе остаётся a***@example.com, а в событии customerId.
Что такое PII
Разработчик пишет в лог email клиента «для отладки», лог уезжает в общее хранилище, и через месяц эту строку читают пять команд, а из поискового индекса её уже не вынуть. Чтобы такого не случалось, нужно заранее знать, какие именно значения нельзя ронять в лог, ответ и сообщение. Это и есть PII (Personally Identifiable Information) — персональные данные, по которым можно идентифицировать конкретного человека. И российский 152-ФЗ, и европейский GDPR относят к ним:
- email, номер телефона;
- ФИО, дату рождения;
- адрес, паспортные данные;
- биометрические данные.
Отдельная строка — IP-адрес и идентификаторы устройств. В Европе спор давно закрыт: суд ЕС признал персональными данными даже динамический IP, если у того, кто его хранит, есть законная возможность связать адрес с человеком. В российской практике однозначного ответа нет — позиции регулятора и судов расходятся. Разработчику от этого спора ни холодно ни жарко: в логах IP и идентификатор устройства всё равно обращаются осторожно, как с персональными данными, — так вы точно не ошибётесь.
Ключевое правило: PII живут только там, где они нужны для дела — в таблице клиентов, в форме профиля, в письме самому клиенту. В лог, в текст ошибки и в сообщение другому сервису уходит идентификатор, а не email и телефон.
PII в логах
Самая распространённая утечка — через журналирование: та самая строка «для отладки», которую забыли убрать.
// Плохо — email и телефон попадают в лог
log.info("User registered: email={} phone={}", user.email(), user.phone());
// Хорошо — только внутренний идентификатор
log.info("User registered: userId={}", user.id());
// Если нужна диагностика — маскируйте данные
log.info("Email verification sent: userId={} emailMask={}",
user.id(), maskEmail(user.email())); // u***@example.com
Маскирование несложно реализовать один раз в утилитном классе и использовать везде:
public final class PiiMasking {
public static String maskEmail(String email) {
if (email == null) return "***";
int at = email.indexOf('@');
if (at < 1 || at == email.length() - 1) return "***"; // нет имени или нет домена
return email.charAt(0) + "***@" + email.substring(at + 1);
}
public static String maskPhone(String phone) {
if (phone == null || phone.length() <= 4) return "***";
return "***" + phone.substring(phone.length() - 4);
}
}
Проверки в начале выглядят придирчиво, но каждая закрывает реальный случай. Наивный вариант — разрезать строку по @ и взять первый символ — падает на @example.com (имени нет, брать первый символ не у чего) и на user@ (домена нет, второго куска просто не существует). А maskPhone с условием length() < 4 при ровно четырёх цифрах вернёт ***1234, то есть номер целиком: маскировать стало нечего, а выглядит как замаскированное.
Правило для такой утилиты простое: она не имеет права упасть. Её зовут прямо внутри log.info, и исключение из неё уронит не строку в журнале, а сам запрос пользователя. Всё, что не похоже на адрес или номер, она отдаёт как *** — и на этом успокаивается.
Ещё одна скрытая ловушка — метод toString() у объектов. Если написать log.info("User: {}", user), Slf4j вызовет user.toString(), и если в нём есть поля с PII — они окажутся в логе.
public record Customer(Long id, String email, String phone, String fullName) {
@Override
public String toString() {
return "Customer[id=" + id + "]"; // только id, без персональных данных
}
}
Почему запрет такой строгий: кто читает логи и сколько они живут
Правило «в лог только идентификатор» выглядит перестраховкой, пока не посмотреть, что с логом происходит после записи.
Лог не остаётся на машине сервиса. Сборщик отправляет его в общее хранилище, где лежат логи всех сервисов компании, и читают их все, кому нужно разбирать сбои: дежурные, соседние команды, аналитики, подрядчик по поддержке. Доступ к поиску по логам почти никогда не выдаётся «по сервисам» — он общий, потому что инцидент редко живёт в одном сервисе. Значит, email, записанный «на время отладки», становится доступен людям, которым по работе персональные данные клиента не нужны, а закон требует ограничивать доступ к ним по необходимости.
Второе — срок. Логи хранят месяцами: обычная настройка — от двух недель горячего поиска до года в холодном архиве, потому что расследовать приходится и старые инциденты. Персональные данные при этом полагается хранить не дольше, чем нужно для цели, и удалять по требованию человека. Запись в логе этому не подчиняется: она разошлась по репликам индекса, попала в резервные копии хранилища логов, и удалить из неё одну строку по заявлению клиента технически почти невозможно. Именно поэтому дешевле не писать, чем потом удалять.
Отсюда практический вывод: срок хранения логов и права доступа к ним — часть той же задачи, что и маскирование. Если в организации логи живут год и открыты всем, то любая утечка в лог — это утечка на год для всех. Разделить доступ по индексам, укоротить срок для отладочного уровня и держать отдельный, более закрытый поток для того, что действительно требует данных (журнал действий администратора, например) — всё это уменьшает цену ошибки, которая рано или поздно случится.
PII в адресах и заголовках
Логи — не единственный канал, и не самый очевидный. Второй по частоте — адрес запроса.
GET /api/customers?email=ivan.petrov@example.com
GET /api/orders?phone=79161234567
Формально данные никуда не «печатались», но адрес запроса попадает сразу в несколько мест, и ни одно из них вы не контролируете. Его целиком пишет журнал доступа веб-сервера и балансировщика — а это тот же общий лог, только с другой стороны. Он уходит в браузере в историю и в закладки, а при переходе по внешней ссылке отправляется третьей стороне в заголовке Referer. Его записывает трассировка как имя операции спана. Он остаётся в прокси и CDN между клиентом и вами. И он же попадает в отчёты об ошибках, которые фронтенд отправляет в свой сервис сбора сбоев.
Поэтому персональные данные в строке запроса не передают вовсе. Поиск по личным данным делают запросом с телом: POST /api/customers/search с {"email": "..."}. В пути оставляют непредсказуемый идентификатор — /api/customers/0f8b… вместо /api/customers/ivan.petrov@example.com. И проверяют, что фронтенд не собирает такие адреса сам: ?q= в поиске по клиентам ничем не отличается от ?email=.
Заголовки — тот же канал. Свои X-User-Email, X-Phone между сервисами не заводят: журнал доступа часто настроен записывать заголовки, а шлюз может их проксировать наружу. Между сервисами едет идентификатор, а Authorization с токеном, в котором есть email в claims, не пишут в лог целиком — токен маскируют так же, как пароль.
PII в тексте исключений
Похожая проблема — вкладывать данные в текст исключения. Казалось бы, исключение — это внутреннее дело приложения. Но оно быстро становится публичным: через логи, через отладочный вывод и — что важнее всего — через ответ API.
// Плохо — email оказывается в тексте исключения
throw new InvalidEmailException("Email " + email + " is invalid format");
Что здесь происходит:
- Исключение попадает в лог — PII в логе.
- Обработчик ошибок (
RestControllerAdvice) берётex.getMessage()и кладёт в полеdetailответа. - Пользователь (или злоумышленник) видит подтверждение, что такой email существует или не существует.
Правильный подход — использовать коды ошибок без данных в тексте:
public class InvalidEmailException extends DomainException {
public InvalidEmailException() {
super("INVALID_EMAIL_FORMAT", "Provided email is in invalid format");
}
}
В тексте исключения — общее описание без конкретного значения. Если нужна диагностика — отдельная строка в лог с маскированным значением.
PII в метриках и трассировке
Наблюдаемость обычно обходят в этом разговоре стороной, а она устроена ровно как лог: то, что попало в атрибут спана или в метку метрики, уезжает в общее хранилище, доступно всем и живёт там своим сроком.
Атрибуты спанов. Трассировка напрашивается на подробности: удобно положить в спан email клиента, чтобы найти его запрос. Но хранилище трасс — отдельная система со своими правами доступа и своим сроком хранения, и настроена она обычно свободнее, чем база с клиентами, потому что «там же только техника». В атрибуты кладут идентификаторы: customer.id, order.id, tenant.id. Имя операции спана при этом берут шаблонное (GET /api/customers/{id}), а не фактический адрес — иначе в трассировку приезжает та же строка запроса вместе со всем, что в ней было.
Метки метрик. Здесь запрет строже, и не только из-за данных. Метка метрики — это отдельный временной ряд на каждое значение. orders_total{customer="ivan@example.com"} создаёт по ряду на каждого клиента: сто тысяч клиентов — сто тысяч рядов, и хранилище метрик (Prometheus, Victoria Metrics) складывается под таким количеством. Это называют взрывом размерности, и метки с неограниченным набором значений (email, телефон, идентификатор заказа, адрес запроса с параметрами) в метриках не используют вообще. В метках живут только значения из короткого закрытого списка: код ответа, имя ручки, тип платежа.
Практическое следствие для обоих случаев: клиента ищут по идентификатору, а связь идентификатора с человеком остаётся в той единственной базе, где ей и место. Идентификатор запроса из журнала помогает выйти на трассу, а трасса на сервис — этой цепочки достаточно для разбора, и персональных данных в ней нет ни в одном звене.
Безопасный обработчик ошибок
RestControllerAdvice — центральное место, где собираются все исключения приложения. Здесь легко случайно пробросить внутренние детали в ответ клиенту.
// Плохо — берём текст исключения напрямую
problem.setDetail(ex.getMessage()); // может содержать PII
problem.setDetail(ex.getCause().getMessage()); // и тем более причина
problem.setProperty("stackTrace", ex.getStackTrace()); // полная структура кода
Безопасная реализация — явное сопоставление кода ошибки с заранее написанным текстом:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderDomainException.class)
public ProblemDetail handleOrderDomain(OrderDomainException ex) {
var problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setType(URI.create("urn:order:domain"));
problem.setTitle("Order operation failed");
problem.setDetail(switch (ex.errorCode()) {
case "ORDER_NOT_FOUND" -> "Order with given id not found";
case "ORDER_NOT_CANCELLABLE" -> "Order in current status cannot be cancelled";
default -> "Order operation failed";
});
problem.setProperty("errorCode", ex.errorCode());
return problem;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneric(Exception ex) {
var problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problem.setTitle("Internal server error");
problem.setDetail("An unexpected error occurred. Reference: " + requestId());
return problem;
}
private static String requestId() {
var id = MDC.get("requestId");
return id != null ? id : "n/a";
}
}
Клиент получает понятное сообщение без внутренних деталей. Для поиска по логам достаточно requestId — и он появляется в MDC не сам собой: его кладёт туда фильтр на входе, который берёт значение из заголовка запроса или выдаёт новое. Если фильтра нет или запрос до него не дошёл, MDC.get вернёт null, и клиент увидит Reference: null — выглядит как поломка. Поэтому запасное значение ставят прямо здесь.
Путь текста ошибки от исключения до клиента: граница проходит на поле detail, именно там текст перестаёт быть внутренним.
PII в сообщениях между сервисами
Kafka-топики — это широковещательный канал: событие видят все потребители, подписанные на топик. Если в событие включить email или телефон клиента, они окажутся сразу во всех сервисах, которые это событие обрабатывают.
// Плохо — все потребители топика видят персональные данные
public record OrderConfirmedEvent(
Long orderId,
String customerEmail, // утечка
String customerPhone // утечка
) {}
// Хорошо — только идентификатор, данные запрашиваются при необходимости
public record OrderConfirmedEvent(
Long orderId,
Long customerId,
Money totalAmount
) {}
Если сервису уведомлений нужен email — он запрашивает его напрямую у customer-service: GET /customers/{id}/email. Это даёт точечный доступ и полный журнал, кто и когда запрашивал данные.
Цена совета «запросите у сервиса клиентов»
Правило «в событии только идентификатор» верно, но не бесплатно, и стоит назвать цену прямо. Сервису рассылки, который получил customerId, теперь нужен email, а значит — вызов сервиса клиентов на каждое письмо. Появились новая зависимость и новая точка отказа: сервис клиентов лежит — письма не уходят; отвечает медленно — рассылка встаёт в очередь; выкатывается — рассылка чувствует это на себе.
Что с этим делают на практике:
- Запрашивать пачкой, а не по одному. Рассылка по десяти тысячам клиентов — это один вызов на список идентификаторов, а не десять тысяч вызовов. Отдельная ручка «дай контакты по списку» решает большую часть проблемы производительности.
- Кэшировать на короткий срок с понятным правилом сброса. Контакты меняются редко, минутный кэш снимает нагрузку и переживает короткую недоступность сервиса клиентов. Долгий кэш — уже копия персональных данных, со всеми обязанностями по их удалению.
- Не терять задачу при отказе. Письмо не отправляется в момент обработки события: событие превращается в задачу, а задача повторяется, пока контакты не будут получены. Тогда недоступность сервиса клиентов задерживает рассылку, а не отменяет её.
- Держать копию контактов там, где иначе никак — но осознанно. Если рассылка обязана работать при недоступном сервисе клиентов, она хранит минимум (email и признак согласия), помечает это как персональные данные и обязана применять к ним удаление и исправление вместе со всеми остальными. Это дороже, чем кажется: удаление клиента теперь задача двух сервисов.
Когда данных мало и связь редкая, вызов по требованию всегда проще. Своя копия оправдана только когда без неё функция перестаёт работать в аварии, и тогда её заводят с открытыми глазами, а не потому, что «так быстрее».
Секреты не в git
Пароли от базы данных, ключи внешних сервисов, токены — всё это называют секретами. Главная ошибка: положить их прямо в файл конфигурации и зафиксировать в репозитории.
Плохо — секрет попадёт в историю git:
spring:
datasource:
password: super-secret-password-prod
Хорошо — в файле только ссылка на переменную окружения:
spring:
datasource:
password: ${DB_PASSWORD}
Проблема с git в том, что даже удалённый коммит остаётся в истории, в форках, в кэше CI. Если секрет попал в репозиторий — его нужно считать скомпрометированным и немедленно менять, а не просто удалять из файла.
Где хранить секреты по-настоящему:
- HashiCorp Vault — специализированное хранилище секретов с управлением доступом, ротацией и аудитом. Spring Cloud Vault интегрируется напрямую.
- Kubernetes SealedSecrets — секрет шифруется в репозитории, расшифровывается оператором уже внутри кластера.
- Cloud Secret Manager (AWS Secrets Manager, GCP Secret Manager) — управляемый сервис облака, pod получает секрет через IAM-роль.
- Переменные окружения — базовый вариант, работает везде.
Добавьте в .gitignore файлы, которые никогда не должны попасть в репозиторий:
application-secrets.yml
*.pem
*.key
.env
Обратите внимание, чего в этом списке нет: обычного application-prod.yml. Настройки боевого профиля — адреса, таймауты, размеры пулов — должны лежать в репозитории вместе с кодом, иначе никто не увидит, с чем приложение поедет в прод. Прятать нужно не файл, а значения: пароли и ключи подставляются переменными окружения или приходят из хранилища секретов.
Дополнительная защита — хуки проверки коммитов: они сканируют изменения перед фиксацией и не дают закоммитить строку, похожую на пароль или токен. В стандартах раздела для этого взят gitleaks — он же ставится и в хук перед коммитом, и отдельным шагом в сборку. Есть и другие инструменты того же назначения (trufflehog, git-secrets), но держать в проекте лучше один: два набора правил рано или поздно разойдутся, и исключения придётся заводить дважды.
Места, где значение секрета можно прочитать при четырёх способах доставки: у SealedSecrets путь в кластере тот же, что у обычного Secret, он закрывает только историю git.
Данные в базе: шифрование полей и дампы
Логи, адреса, метрики и события — каналы наружу. Есть ещё два, о которых вспоминают последними: сама база и её резервные копии.
Шифрование чувствительных полей. Не всё, что лежит в базе, требует шифрования: если атакующий получил доступ к приложению, ключ у него тоже есть. Шифрование полей защищает от другого — от чтения файлов базы, украденного дампа, любопытного администратора и от резервной копии, которая уехала не туда. Поэтому шифруют узкий набор: паспортные данные, документы, платёжные реквизиты, медицинские сведения. Ключ при этом живёт не в базе и не в репозитории, а в хранилище секретов, и его смена должна быть возможна — значит, рядом с зашифрованным значением хранят версию ключа.
Цена очевидна, как только доходит дело до поиска: по зашифрованной колонке нельзя сделать WHERE, LIKE и индекс. Обычный выход — хранить рядом детерминированный хеш с солью, по которому ищут точное совпадение (WHERE email_hash = ?), а сам email держать зашифрованным; поиск по подстроке для таких полей становится невозможен, и это правильная цена. На уровне базы есть pgcrypto для шифрования в запросе и полнодисковое шифрование у провайдера. Полнодисковое закрывает украденный диск, но не украденный дамп: для дампа оно уже расшифровано.
Дампы. Резервная копия — это полный снимок персональных данных, и вокруг неё те же вопросы, что вокруг лога: кто может скачать, где лежит, сколько хранится, зашифрована ли. Отдельная и самая частая ошибка — боевой дамп в тестовой среде: он моментально превращает тест в такую же охраняемую систему, только без охраны. В тестовой среде работают либо на сгенерированных данных, либо на обезличенном срезе, и делают это автоматически при восстановлении, а не по памяти. Как обезличивают и что именно ломается при наивной замене — в статье про обезличивание данных.
Глубже: проверки в сборке: секреты, зависимости, образырасширенное
«Секреты не в git» держится на привычке, а привычка ломается в пятницу вечером. Надёжнее, когда утечку и уязвимость ловит сборка, и для этого есть четыре проверки, по одной на каждую дверь, через которую в сервис попадает чужой код и свои секреты.
Поиск секретов. gitleaks или trufflehog в хуке перед коммитом и в CI на каждый push сканируют изменения по шаблонам ключей (AWS, токены GitHub, приватные ключи, строки подключения с паролем) и по энтропии. В CI сканируют не только диф, но и историю, потому что секрет, закоммиченный и удалённый следующим коммитом, остаётся в репозитории навсегда. Найденный секрет считается утёкшим и ротируется, а не «удаляется из истории».
Зависимости. Уязвимость в библиотеке это ваша уязвимость, и Log4Shell это доказал. OWASP Dependency-Check, Trivy fs или сервисы вроде Snyk сверяют дерево зависимостей с базами известных уязвимостей и падают на критичных. Рядом с этим стоит список того, из чего сервис собран, SBOM в формате CycloneDX (плагин для Gradle генерирует его за один шаг), чтобы при следующем Log4Shell ответить «нас это касается или нет» за минуту, а не за день. И проверка целостности: gradle --write-verification-metadata sha256 фиксирует контрольные суммы артефактов, чтобы подменённая библиотека из репозитория не прошла сборку. Обновления зависимостей ставят на автомат (Renovate, Dependabot), потому что накопленные за год обновления никто не решится накатить.
Образы. Базовый образ содержит операционную систему с её уязвимостями. Trivy image или Grype сканируют собранный образ перед публикацией; базовый образ выбирают минимальный (distroless или -jre без лишнего) и закрепляют по дайджесту, а не по тегу latest, который меняется под ногами. Подпись образа (cosign) и проверка подписи при развёртывании закрывают подмену между реестром и кластером.
Статический анализ кода. SAST ищет в самом коде склейку запросов, слабую криптографию, десериализацию недоверенных данных: Semgrep с правилами для Java и Spring, SpotBugs с FindSecBugs, SonarQube. Ловит он меньше, чем обещает, и шумит, поэтому начинают с десятка правил под свои грабли, а не с тысячи.
Порядок внедрения: поиск секретов первым, он дешевле всех и закрывает самую дорогую ошибку; потом сканирование зависимостей с порогом «критичные»; потом образы; SAST последним. Каждая проверка это шаг в CI с падением сборки, а не отчёт, который никто не читает; и у каждой есть список исключений с датой и причиной, иначе через месяц её выключат.
Глубже: утекло: порядок действийрасширенное
Каждый раздел выше заканчивается словами «считайте скомпрометированным» и останавливается. Вот что делать дальше, по видам утечки, и общее правило: сначала перекрыть, потом расследовать, потом чистить следы. Обратный порядок оставляет дверь открытой на время расследования.
Утёк секрет (пароль базы, ключ API, client secret). Выпустить новый, выкатить сервис с новым, отозвать старый, в таком порядке, потому что отзыв первым роняет прод. У большинства систем есть окно с двумя действующими секретами именно для этого. Потом смотреть журнал: чем старый секрет пользовались после утечки. Удаление из истории git (git filter-repo) делают для порядка, но оно ничего не отменяет: секрет уже скопирован.
Утёк ключ подписи токенов. Это худший случай: владелец ключа выписывает любые токены для любого пользователя. В Keycloak добавляют новый ключ realm с более высоким приоритетом, и новые токены подписываются им; старый переводят в режим «только проверка» на время жизни выданных токенов (минуты для access, дни для refresh), затем отключают. Сессии отзывают целиком («Logout all» и политика not-before в realm), пользователи входят заново, и сервисы, которые проверяют подпись локально, обязаны перечитать JWKS, что они делают сами по промаху ключа. Пока старый ключ жив, каждый токен с ним подозрителен.
Утёк refresh или сессия одного пользователя. Отозвать сессии пользователя, выписать новые токены после повторного входа, проверить действия под этой сессией в журнале, сообщить пользователю. Здесь пригождается ротация refresh с обнаружением повторного использования, о чём статья про токены.
Утекли персональные данные. Здесь начинается не только техника. Зафиксировать, что именно, чьи, за какой период и как утекло, с отметками времени, потому что срок уведомления считают от момента обнаружения: по 152-ФЗ оператор сообщает в Роскомнадзор в первые сутки и досылает результаты разбора в трое суток, по GDPR надзорный орган уведомляют за 72 часа. Уведомить пользователей, если утечка им угрожает (пароли, платёжные данные, документы). Принудительно сменить пароли и отозвать сессии, если утекли учётные данные. Закрыть дыру, через которую ушло, и только потом чистить: копии в логах, бэкапах, кэшах, у подрядчиков.
Общее. Один человек ведёт хронологию инцидента с первой минуты, и это не тот, кто чинит. Доступы всех, кто участвовал, и всех сервисов, куда мог дотянуться утёкший секрет, пересматривают после. И разбор без поиска виноватых: вопрос не «кто закоммитил», а «почему сборка это пропустила», и ответ на него это проверки из предыдущего раздела.
Коротко
- PII — email, телефон, ФИО, адрес и похожие данные: в логи их не выводят ни на каком уровне, даже DEBUG, а для диагностики маскируют одним утилитным классом (
u***@example.com,***1234). toString()объектов с PII переопределяют так, чтобы выводился только идентификатор, а текст исключений идёт без значений данных: вRestControllerAdvice— явное сопоставление кодов, никакогоex.getMessage()вdetail.- В Kafka-события — только идентификаторы. Нужны данные — запрашивайте через API конкретного сервиса.
- Секреты никогда в git: только переменные окружения или хранилище секретов, а секрет, попавший в историю репозитория, считают скомпрометированным и меняют.
- Сборка ловит то, что привычка пропускает:
gitleaksпо истории, сканирование зависимостей с SBOM и проверкой контрольных сумм, сканирование образов с закреплением по дайджесту, SAST с малым набором правил; каждая проверка роняет сборку, а не пишет отчёт. - Утекло: сначала перекрыть, потом расследовать, потом чистить; секрет ротируют с окном двух действующих, ключ подписи меняют с отзывом сессий, при утечке персональных данных счёт идёт на сутки по 152-ФЗ и 72 часа по GDPR.
- Адрес запроса — такой же канал утечки, как лог: он попадает в журнал доступа, историю браузера,
Refererи трассировку. Поиск по личным данным — запросом с телом, в пути только непредсказуемый идентификатор. - В атрибуты спанов и метки метрик кладут идентификаторы, а не данные; для метрик это ещё и взрыв размерности. Логи и трассы живут месяцами и открыты многим: срок хранения и права доступа — часть той же задачи, что маскирование.
- Шифруют узкий набор полей (документы, платёжные реквизиты), ключ — в хранилище секретов; поиск тогда по детерминированному хешу рядом, а боевой дамп в тестовой среде заменяют обезличенным срезом.
Что почитать дальше
- Логирование и наблюдаемость — полные правила журналирования.
- Обработка ошибок и RFC 9457 — как правильно формировать ответы об ошибках.
- Kafka: проектирование событий — что включать в события.
- Аудит административных команд — как фиксировать действия без утечки данных.