Наблюдаемость¶
Модуль CM Ocean Loyalty спроектирован под промышленную эксплуатацию в Kubernetes:
структурные JSON-логи со сквозным traceId, метрики в формате Prometheus, а также
health-пробы для оркестратора. Все эти сигналы доступны через специально выделенные
публичные management-эндпоинты, не требующие авторизации, что позволяет инфраструктуре
заказчика (k8s, Prometheus, системы сбора логов) опрашивать сервис без интеграции с
Keycloak.
Разделение портов¶
Прикладной REST-API (/v1/...) и management-эндпоинты разнесены по разным сетевым портам.
Это стандартная для платформы модель: бизнес-трафик и операционные пробы не пересекаются,
а management-порт не выставляется наружу через внешний ingress.
| Порт | Назначение | Доступ |
|---|---|---|
| 8080 | Прикладной REST-API /v1/*; на нём же (не под /v1) без авторизации отдаются спецификация OpenAPI и Swagger UI |
/v1/* — за OAuth2 (JWT), deny-by-default; OpenAPI/Swagger — публично, явным исключением из правил |
| 9090 | Management: только health, метрики (Prometheus), info | Публичный (без авторизации) |
Базовый путь management
Management-эндпоинты сконфигурированы с базовым путём / (а не привычным /actuator).
Поэтому фактические пути идут без префикса /actuator: /health, /info, а
Prometheus замаплен на /metrics. Это учтено в правилах безопасности — данные пути
объявлены публичными явным списком.
Публичные эндпоинты¶
Конфигурация безопасности (stateless OAuth2 resource server, deny-by-default) делает
публичными только следующие пути; всё остальное требует валидного JWT и соответствующей
роли LOYALTY_*. Три первых живут на management-порту 9090, остальные — на прикладном порту 8080
(там же, где /v1/*, но вне общего правила авторизации).
| Метод | Путь | Порт | Назначение |
|---|---|---|---|
GET |
/health |
9090 | Агрегированный статус сервиса |
GET |
/health/liveness |
9090 | Liveness-проба k8s |
GET |
/health/readiness |
9090 | Readiness-проба k8s |
GET |
/metrics |
9090 | Метрики в формате Prometheus |
GET |
/info |
9090 | Информация о сборке/версии |
GET |
/v3/api-docs |
8080 | Спецификация OpenAPI (JSON) |
GET |
/v3/api-docs.yaml |
8080 | Спецификация OpenAPI (YAML) |
GET |
/swagger-ui.html, /swagger-ui/** |
8080 | Интерактивный обозреватель API |
Сетевое ограничение доступа
Перечисленные эндпоинты не требуют JWT. Три management-эндпоинта (health/metrics/info) ограничивает сетевой контур: порт 9090 должен быть доступен только внутренней инфраструктуре (k8s, Prometheus, сборщик логов) и не публиковаться во внешний ingress. OpenAPI и Swagger UI живут на прикладном порту 8080 и публичны по правилам приложения, а не сети — их доступность наружу управляется явным списком путей в конфигурации безопасности, не сетевой изоляцией.
Health-пробы Kubernetes¶
Сервис предоставляет раздельные liveness- и readiness-пробы, что соответствует модели жизненного цикла пода в Kubernetes.
| Проба | Путь | Семантика |
|---|---|---|
| Liveness | GET /health/liveness |
Процесс жив; при сбое — рестарт пода |
| Readiness | GET /health/readiness |
Готов принимать трафик; при сбое — вывод из балансировки |
| Общий статус | GET /health |
Агрегированное состояние (компоненты, зависимости) |
Конфигурация проб в манифесте
Привязка livenessProbe/readinessProbe к путям /health/liveness и
/health/readiness задаётся в Helm-чарте поставки. Значения порогов
(initialDelay, period, timeout) подбираются под целевое окружение заказчика.
Метрики Prometheus¶
Метрики экспортируются в текстовом формате Prometheus по адресу GET /metrics
(порт 9090). Их забирает Prometheus-инстанс контура заказчика; визуализация и
алертинг выполняются на стороне заказчика (например, Grafana).
В состав попадают как стандартные технические метрики рантайма (JVM, пулы соединений, HTTP-вызовы, латентность), так и инфраструктурные показатели сервиса. Целевые нефункциональные ориентиры контура — латентность начисления/списания ≤ 300 мс и чтения данных ≤ 500 мс — отслеживаются именно через эти метрики.
Доменная антифрод-наблюдаемость — в дорожной карте
Специализированный мониторинг аномалий по баллам (доменная антифрод-наблюдаемость, real-time правила со скользящими окнами) относится к развитию контура и в текущей версии модуля не реализован.
Метрики денежных операций¶
Каждая денежная операция (начисление, списание, холд, подтверждение/отмена холда, обмен, сгорание, корректировка) метрицируется на общем узле записи в журнал операций — двумя величинами сразу:
| Метрика | Тип | Теги | Что показывает |
|---|---|---|---|
loyalty.ledger.operations |
Counter | kind (вид операции), outcome (succeeded | rejected:<код>) |
Число операций по виду и исходу |
loyalty.ledger.amount.minor |
Counter | kind, outcome |
Сумма операций в минорных единицах — для отклонённых по нехватке средств это запрошенная сумма, а не нулевая |
loyalty.ledger.posting |
Timer | — | Время самой проводки (от определения счетов до сохранения записи леджера) |
Разбивка по outcome обязательна: отказ по нехватке средств — нормальный бизнес-исход, а не ошибка
системы, и мерить его вместе с успехами значило бы не видеть ни того, ни другого. Число операций без
суммы вводит в заблуждение так же, как сумма без числа (тысяча начислений по рублю и одно на миллион
выглядят одинаково на графике одной метрики).
Метрики тегов, меток акций и условий RT Flow¶
| Метрика | Тип | Теги | Что показывает |
|---|---|---|---|
loyalty.customer.tag.changes |
Counter | action (assigned/removed/merged/deleted) |
Изменённые связи клиента с тегом |
loyalty.promotion.label.changes |
Counter | action (assigned/removed/merged/deleted) |
Изменённые связи акции с меткой |
loyalty.integration.rtflow.condition_checks |
Timer | outcome (ok/invalid/not_found/error) |
Проверки условий по тегам/меткам/акциям из POST /v1/integration/rtflow/condition-checks |
loyalty.integration.rtflow.condition_checks.unresolved |
Counter | — | Ссылки на несуществующие теги, метки или акции в проверках RT Flow — рост означает сценарии, сломанные удалением значения справочника |
Метрики фоновых подсистем¶
| Метрика | Тип | Что показывает |
|---|---|---|
loyalty.intake.oldest.pending.seconds |
Gauge | Возраст самого старого необработанного (созревшего) сообщения очереди приёма — основной показатель для дежурства, не длина очереди |
loyalty.intake.pending / .parked / .scheduled |
Gauge | Объём ожидающих / отложенных в разбор / запланированных на будущее команд |
loyalty.intake.commands |
Counter (outcome, type) |
Принято/исполнено/повторено/отложено/уступило очередь по типу команды |
loyalty.intake.kafka.malformed |
Counter | Сообщения топика приёма, которые не удалось разобрать как команду |
loyalty.exchange.lag.seconds |
Multi-gauge (job) |
Сколько секунд назад заканчиваются уже выгруженные данные задания обмена (-1 — задание ни разу не отработало) |
loyalty.exchange.runs |
Counter (job, status) |
Прогоны регламентных заданий обмена данными |
loyalty.reconciliation.diffs |
Counter (scope, severity) |
Расхождения, найденные сверкой итогов и целостности баланса |
loyalty.ratelimit |
Counter (rule, outcome) |
Срабатывания правил ограничения частоты (allowed/observed/limited/fail_open) |
loyalty.audit.seal.broken |
Gauge | Число печатей журнала аудита, не прошедших ночную проверку целостности (в норме — 0) |
loyalty.audit.seal.uncovered |
Gauge | Записи журнала аудита после последней печати, которые исправная постановка печатей уже обязана была покрыть, — старше окна lag + 2 × tick (по умолчанию 11 минут); считается той же ночной проверкой. В норме — 0, рост означает, что печати перестали ставиться |
loyalty.siem.sent / .failed |
Counter | Отправленные/неудачные попытки передачи событий в SIEM |
loyalty.siem.backlog |
Gauge | Отставание потока в SIEM — сколько записей журнала ещё не отдано |
loyalty.ledger.partitions.runway.days |
Gauge | Запас нарезки партиций леджера в днях (отрицательное значение — нарезки нет вовсе) |
loyalty.ledger.partitions.default.rows |
Gauge | Строк, осевших в страховочной DEFAULT-партиции — в норме 0 |
loyalty.ledger.integrity.duplicate.entries / .orphan.lots |
Gauge | Результат ночной проверки целостности леджера (дубликаты идентификатора проводки, лоты без проводки) — в норме 0 |
loyalty.points.expiry.last.run.seconds |
Gauge | Момент последнего успешного боевого прогона сгорания (epoch-секунды), в том числе пустого; пробный прогон его не меняет |
loyalty.points.expiry.last.burned.minor / .last.lots |
Gauge | Итоги последнего прогона сгорания: сожжённая сумма в минорных единицах и число лотов |
loyalty.points.expiry.scheduled |
Gauge | 1, если задано расписание сгорания (LOYALTY_EXPIRY_CRON), иначе 0 |
loyalty.notifications.pending / .parked |
Gauge | Сообщения очереди исходящих уведомлений: ожидают отправки / отложены в разбор |
loyalty.notifications.oldest.pending.seconds |
Gauge | Возраст самого старого созревшего сообщения, ждущего отправки: растёт и при отказе канала, и при остановленной отправке |
loyalty.accounts.opening.unopenable |
Counter (currency, source) |
Случаи, когда клиенту не удалось открыть счёт во включённой валюте (нет базового субсчёта или плана счетов) |
loyalty.purchase.store.unresolved |
Counter | Покупки с кодом торговой точки, которого нет в справочнике: гео-условия акций такую покупку не пропускают |
loyalty.purchase.category.mismatch |
Counter | Покупки, где категория из события отличается от категории товара, найденного в каталоге по артикулу (покупку не блокирует) |
loyalty.partner.transaction.mid.unresolved |
Counter | Партнёрские транзакции, для которых по MID не нашёлся мерчант или мерчант не отнесён к партнёру |
loyalty.segments.fills |
Counter (status) |
Заливки сегментов клиентов по статусу, в который они перешли (OPEN, COMPLETED, ABANDONED, REJECTED) — завершилась ли ночная заливка |
loyalty.segments.fill.complete |
Timer | Длительность применения заливки к составу сегмента |
loyalty.segments.members.entered / .exited |
Counter | Клиенты, вошедшие в сегменты и вышедшие из них при завершении заливок |
distributed.lock.acquire / .unlock |
Timer | Время захвата/снятия распределённой блокировки Postgres |
Показатели прогона сгорания — в памяти пода
loyalty.points.expiry.last.run.seconds и итоги прогона хранятся в памяти пода: после перезапуска
пода до первого прогона значение — 0. Прогон выполняется один на кластер, поэтому у реплики, которая
прогон не выполняла, значение не обновляется. Правило LoyaltyExpiryStalled (см. ниже) проверяет
каждый под отдельно, поэтому при заданном расписании оно срабатывает через 15 минут после выкладки или
перезапуска пода и держится, пока этот под сам не выполнит прогон; у реплики, которой прогон не
достаётся, оно не гаснет. При разборе срабатывания сверяйте время последнего прогона по всем репликам и журнал
приложения, а не значение одного пода.
Оповещение «печати журнала аудита перестали ставиться»
loyalty.audit.seal.broken такой отказ не видит: когда печати не ставятся вовсе, сломанных среди
них нет. Его ловит loyalty.audit.seal.uncovered, и правило loyalty_audit_seal_uncovered > 0
ложно не срабатывает. Печать ставится раз в loyalty.audit.seal.tick и берёт записи старше
loyalty.audit.seal.lag, поэтому между печатями свежие записи лежат без неё законно. В показатель
входят только записи старше окна lag + 2 × tick: отставание, один период ожидания очередной
печати и ещё один — запас на длительность самих прогонов. На исправном контуре показатель — 0 в
любой момент, а не только сразу после очередной печати.
Оба показателя обновляет проверка целостности по расписанию loyalty.audit.seal.verify-cron (по
умолчанию ежедневно в 03:25 UTC: пояс JVM закреплён в образе и от пояса пода не зависит), поэтому
оповещение и срабатывает, и гаснет с задержкой до суток: печати, восстановившиеся днём, снимут его
только следующей проверкой.
До первой проверки после старта пода оба показателя — 0: это «ещё не проверяли», а не «всё
покрыто». Мгновенный ответ даёт GET /v1/admin/audit/integrity, поле uncovered. Показатели
публикуются только при включённых печатях (LOYALTY_AUDIT_SEAL_ENABLED=true). В PrometheusRule
чарта этого правила нет — его заводит контур, где печати включены.
Оповещения (Prometheus / Alertmanager)¶
Чарт loyalty-api включает шаблон PrometheusRule с готовыми правилами оповещений — по техническим
и по бизнес-показателям. Правила подхватываются, только если в кластере установлен Prometheus
Operator; сам ресурс рендерится под переключателем alerting.enabled (по умолчанию false,
отдельно от service.metrics.enabled — метрики собираются всегда, а правила осмысленны только там,
где есть кому их подхватить).
| Alert | Условие | Severity | For |
|---|---|---|---|
LoyaltyBillingDown |
up{job=~".*loyalty-billing.*"} == 0 |
critical | 5m |
LoyaltyBillingIntakeStalled |
loyalty_intake_oldest_pending_seconds выше порога |
warning | 5m |
LoyaltyBillingCommandsParked |
loyalty_intake_parked выше порога |
warning | 15m |
LoyaltyBillingServerErrors |
доля ответов 5xx выше порога |
critical | 10m |
LoyaltyAnomalousOperations |
loyalty_monitoring_anomalies выше порога за сутки |
warning | 30m |
LoyaltyManualAccrualsSpike |
loyalty_monitoring_manual_operations выше порога за сутки |
warning | 30m |
LoyaltyNotificationsStuck |
loyalty_notifications_oldest_pending_seconds выше порога (notificationsStuckSeconds, по умолчанию 3600 с) — уведомления клиентам не уходят |
warning | 10m |
LoyaltyExpiryStalled |
расписание сгорания задано (loyalty_points_expiry_scheduled == 1), а последний успешный прогон был раньше порога (expiryStaleSeconds, по умолчанию 129600 с — сутки и запас) |
warning | 15m |
LoyaltyExpiryStalled срабатывает по факту работы прогона сгорания, а не по объёму предстоящего
сгорания, как раньше: прежнее правило срабатывало на большой пачке предстоящих сгораний и молчало, когда
прогон не запускался неделями. На контуре без расписания сгорания правило не срабатывает. Ранее правило
могло не срабатывать вовсе: показатель loyalty_points_expiry_scheduled через некоторое время после
старта отдавал NaN — это исправлено. Порог alerting.thresholds.expiringMinor сохранён только для
панелей: оповещения по объёму предстоящего сгорания больше нет.
Пороги вынесены в параметры чарта (alerting.thresholds.*) — на нагруженном проде и на
демо-стенде «много ручных операций за сутки» это разные числа, поэтому единого встроенного порога
нет; конкретные значения для контура задаются в его собственном файле значений чарта. Метки, по
которым Prometheus Operator отбирает правило (alerting.labels), тоже задаются контуром — без
совпадения меток с селектором оператора правило не будет подхвачено.
Структурные логи и сквозной traceId¶
Логи пишутся на Log4j2. Формат зависит от Spring-профиля json (активируется переменной
SPRING_PROFILES_ACTIVE=json, включён в чарте на dev/qa/prod): без профиля — цветной текст для
локальной разработки, с профилем — структурный JSON, который можно напрямую отправлять в систему
сбора логов заказчика (например, ELK/Loki) без дополнительного парсинга.
Каждая запись несёт контекст из MDC (Mapped Diagnostic Context), заполняемый инфраструктурными компонентами автоматически — вручную его не переопределяют:
| Поле | Кто устанавливает | Содержимое |
|---|---|---|
traceId |
Фильтр запроса | Значение заголовка X-Request-Id или сгенерированный идентификатор; пробрасывается в ответный заголовок |
actor |
Перехватчик JWT | preferred_username из токена (если отсутствует — sub); пусто для неаутентифицированных запросов (health, swagger) |
userId |
Перехватчик JWT | sub из токена — технический идентификатор субъекта в Keycloak |
{
"timestamp": "2026-06-25T10:15:42.318Z",
"level": "INFO",
"traceId": "a1b2c3d4e5f60718",
"actor": "operator1",
"userId": "3f2a...",
"logger": "loyalty.billing.account.AccrualService",
"message": "accrual committed",
"thread": "http-nio-8080-exec-3"
}
Связка с распределённой трассировкой
traceId сквозной в пределах сервиса и пригоден для корреляции логов одного запроса.
Экспорт трасс во внешнюю систему распределённой трассировки настраивается на уровне
окружения заказчика.
Маскирование секретов — только для слова password
Конфигурация логирования автоматически маскирует значение после ключевого слова password в
любом формате записи (password: secret → password: ***). Для прочих секретных полей
(token, secret, apiKey) автоматического маскирования нет — их не логируют вовсе либо
маскируют вручную в коде, который их обрабатывает.
Аспект логирования¶
Вход и выход из методов продуктовых компонентов логируются автоматически аспектом, а не ручными
log.debug-вызовами в каждом методе: @Service — на уровне TRACE (с аргументами и результатом),
контроллеры и репозитории — на уровне DEBUG (без значений аргументов). Инфраструктурные пакеты
(config, security, error, jdbc, web, utils) аспект не покрывает — там нет бизнес-логики.
- Отказ вызова аспект не маскирует и не глушит. Упавший вызов отмечается в логе отдельной меткой длительности и причины отказа — по логу видно не только то, что метод завершился успешно, но и сколько он проработал до отказа, если сорвался.
- Отсутствие имён параметров не роняет вызов, который аспект описывает. Если по сборке метода имена параметров недоступны (интерфейс, класс без отладочной информации), аспект подставляет позиционные имена вместо параметра и продолжает работать — раньше такая ситуация приводила к падению самого рабочего вызова, а не только записи в лог.
- Уровень
TRACEв production включайте с осторожностью: аспект логирует значения аргументов@Service-методов, и если они содержат персональные данные (имя, телефон, email), включение TRACE в проде становится каналом утечки ПДн в лог. Идентификаторы и суммы в аргументах — допустимы.
Сквозной журнал аудита¶
Сквозной журнал аудита — изменяющие вызовы API и обращения к персональным данным при чтении
(просмотр карточки, поиск, выгрузка), защита от подделки задним числом (печати) и передача потока в
SIEM заказчика — полностью описан в руководстве администратора, раздел
«Безопасность» → «Журнал аудита» (состав записи, виды обращения,
GET /v1/admin/audit, GET /v1/admin/audit/export, GET /v1/admin/audit/integrity,
loyalty.audit.seal.*, loyalty.siem.*). Здесь не дублируется, чтобы описание не расходилось при
следующих релизах; связанные метрики (loyalty.audit.seal.broken, loyalty.audit.seal.uncovered,
loyalty.siem.backlog) — см.
«Метрики фоновых подсистем» выше.
Журнал молчаливой деградации¶
Часть путей продукта намеренно деградирует, а не отказывает целиком, когда вспомогательный источник данных недоступен или испорчен — например, витрина клиента в кабинете отдаётся без уровня (тира), если получить его не удалось, а не падает целиком; акция с испорченным условием охвата не блокирует обработку покупки. Сама деградация — правильное поведение продукта, но раньше такие места глушили исключение молча: настоящий дефект (ошибка данных, недоступная база) выглядел неотличимо от штатного «у клиента просто нет уровня» и не оставлял ничего, по чему его можно было бы найти при разборе инцидента.
Такие места теперь пишут предупреждение в лог перед тем, как проглотить отказ, и называют
пострадавший объект (клиента, акцию), а не только текст исключения — по сообщению в логе видно, что
именно деградировало и почему, вместо того чтобы узнавать об этом только от жалобы клиента.
Аналогичный принцип действует шире — недоступность внешнего MDM отдаётся отдельным кодом
503 mdm_unavailable, а не подменяется похожим на штатный ответом (см. «Конфигурация» → «Внешний
справочник и MDM»).
Очередь исходящих уведомлений¶
Уведомления клиентам (о начислении, списании, скором и состоявшемся сгорании) не отправляются из
денежной операции напрямую, а ставятся в очередь исходящих в БД и публикуются в брокер отдельным
отправителем. Пока отправка выключена (LOYALTY_NOTIFICATIONS_ENABLED=false, значение по умолчанию),
сообщения копятся в очереди и убираются по сроку годности. За состоянием очереди следят показатели
loyalty.notifications.pending, .parked, .oldest.pending.seconds и оповещение
LoyaltyNotificationsStuck; сообщения, отложенные в разбор, читаются через GET /v1/admin/notifications.
Настройки и разбор — «Конфигурация» → «Уведомления клиентам: очередь исходящих».
OpenAPI¶
Спецификация API формируется по принципу code-first и публикуется на прикладном порту 8080 (том
же, где /v1/*): GET /v3/api-docs (JSON), GET /v3/api-docs.yaml (YAML), а также интерактивный
обозреватель GET /swagger-ui.html. Эти ресурсы публичны, что упрощает интеграцию
смежных модулей контура (Promo, RT-Flow/SDE, Core UI) и внешних потребителей.
Сводная диаграмма потоков¶
flowchart LR
K8s[Kubernetes] -->|GET /health/liveness, /health/readiness| MGMT[Порт 9090]
PROM[Prometheus] -->|GET /metrics| MGMT
LOGS[Сборщик логов / ELK] -->|JSON-логи + traceId| SVC[loyalty-billing]
SVC --> MGMT
CLIENT[Потребители API] -->|JWT, /v1/*| API[Порт 8080]