Перейти к содержанию

Наблюдаемость

Модуль 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 Агрегированное состояние (компоненты, зависимости)
# Пример проверки готовности (внутренний порт 9090)
curl -s http://localhost:9090/health/readiness
{
  "status": "UP"
}

Конфигурация проб в манифесте

Привязка livenessProbe/readinessProbe к путям /health/liveness и /health/readiness задаётся в Helm-чарте поставки. Значения порогов (initialDelay, period, timeout) подбираются под целевое окружение заказчика.

Метрики Prometheus

Метрики экспортируются в текстовом формате Prometheus по адресу GET /metrics (порт 9090). Их забирает Prometheus-инстанс контура заказчика; визуализация и алертинг выполняются на стороне заказчика (например, Grafana).

curl -s http://localhost:9090/metrics

В состав попадают как стандартные технические метрики рантайма (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]