← Назад к списку тем

05. Бизнес-метрики: Micrometer, Prometheus, Grafana

MeterRegistry и типы метрик (Counter/Gauge/Timer), механика PrometheusMeterRegistry и pull-модели сбора, теги и проблема кардинальности, экспозиция в Kubernetes через Prometheus Operator, визуализация и алертинг в Grafana, тестирование метрик.

Зачем это нужно: технические метрики не отвечают на бизнес-вопросы

У типичного Spring Boot приложения из коробки есть технические метрики — CPU, heap, latency HTTP-эндпоинтов, размер пула соединений к БД. Они отвечают на вопрос «здоров ли сервис», но не отвечают на вопросы бизнеса: «сколько заказов оформлено за последний час», «какая доля платежей падает с ошибкой шлюза», «сколько активных корзин прямо сейчас». Без явно собираемых бизнес-метрик эти вопросы решаются одним из двух способов — либо ждут жалобы от клиентов и саппорта, либо инженер вручную пишет SQL-запрос в проде посреди инцидента, теряя время именно тогда, когда оно критично.

Вторая проблема — задержка обнаружения деградации. Технический дашборд может быть зелёным (CPU в норме, HTTP 200, latency в порядке), пока платёжный шлюз молча возвращает успешный HTTP-ответ с бизнес-статусом «отклонено» — сервис технически здоров, но бизнес-процесс сломан. Только метрика уровня предметной области (payments_declined_total) делает такую деградацию видимой на дашборде и позволяет настроить алерт до того, как проблему заметят по выручке.

🎯 Итоговый результат этой темы. Приложение публикует именованные, размеченные тегами счётчики и таймеры бизнес-событий через Micrometer; Prometheus, развёрнутый в Kubernetes-кластере (обычно через Prometheus Operator), периодически стягивает их с эндпоинта /actuator/prometheus каждого пода; Grafana строит дашборды и алерты поверх этих временных рядов через PromQL.

Базовое использование: MeterRegistry, Counter, Timer

Зависимости — Spring Boot Actuator (эндпоинты, в том числе /actuator/prometheus) и мост к формату Prometheus:

// build.gradle
implementation "org.springframework.boot:spring-boot-starter-actuator"
implementation "io.micrometer:micrometer-registry-prometheus"
# application.yml — включаем эндпоинт и глобальные теги
management:
  endpoints:
    web:
      exposure:
        include: "health,prometheus"
  metrics:
    tags:
      application: ${spring.application.name} # тег на КАЖДОЙ метрике — обязателен для отличия сервисов в общем Prometheus

Достаточно подключить зависимости — Spring Boot автоконфигурирует бин MeterRegistry, который дальше внедряется как обычная зависимость и используется для регистрации метрик:

@Component
class OrderMetrics {

    private final Counter ordersPlacedCounter;
    private final Timer orderProcessingTimer;

    OrderMetrics(MeterRegistry registry) {
        this.ordersPlacedCounter = Counter.builder("orders_placed_total")
                .description("Количество успешно оформленных заказов")
                .tag("channel", "web")
                .register(registry);

        this.orderProcessingTimer = Timer.builder("order_processing_duration_seconds")
                .description("Время обработки заказа от создания до подтверждения")
                .register(registry);
    }

    void recordOrderPlaced() {
        ordersPlacedCounter.increment();
    }

    <T> T recordProcessingTime(Supplier<T> work) {
        return orderProcessingTimer.record(work);
    }
}

@Service
class OrderService {

    private final OrderMetrics metrics;
    private final OrderRepository orderRepository;

    @Transactional
    Order placeOrder(OrderRequest request) {
        return metrics.recordProcessingTime(() -> {
            Order order = orderRepository.save(Order.from(request));
            metrics.recordOrderPlaced();
            return order;
        });
    }
}
🔑 Альтернатива для простых случаев — @Timed. Вместо ручной обёртки метода в Timer.record(...) можно разметить метод аннотацией @Timed("order_processing_duration_seconds") — таймер соберётся автоматически вокруг вызова через AOP. Это удобно для быстрого старта, но менее гибко: нельзя добавить бизнес-теги, зависящие от результата выполнения (например, status=success|failed), как это легко сделать вручную через Timer.Sample.

Механика внутри: MeterRegistry, PrometheusMeterRegistry и pull-модель

MeterRegistry — это фасад-абстракция Micrometer, не привязанная к конкретной системе мониторинга: один и тот же код с Counter.builder(...).register(registry) одинаково работает с Prometheus, Datadog, CloudWatch или сразу несколькими системами одновременно, потому что Spring Boot по умолчанию регистрирует CompositeMeterRegistry, которая просто веерно транслирует каждую метрику во все настроенные реализации (в нашем случае — единственную, PrometheusMeterRegistry).

PrometheusMeterRegistry внутри себя оборачивает низкоуровневый клиент io.prometheus.client: каждый вызов counter.increment() или timer.record(...) немедленно обновляет число в памяти этого клиента — сбора «по расписанию» для Counter/Timer не существует, они всегда актуальны. Экспозиция наружу работает принципиально иначе, чем можно было бы ожидать по аналогии с логами или трейсами — это не push (сервис сам никуда не отправляет данные), а pull: Prometheus-сервер сам, по расписанию (обычно каждые 15–30 секунд), делает HTTP GET на эндпоинт /actuator/prometheus каждого пода и читает текущий снимок всех метрик в текстовом Prometheus exposition format:

// Упрощённо: то, что делает PrometheusScrapeEndpoint на каждый GET-запрос
@GetMapping(produces = "text/plain; version=0.0.4")
String scrape() {
    return prometheusRegistry.scrape(); // читает текущие значения из памяти, ничего не пересчитывает
}

// Результат в текстовом формате — то, что реально видит Prometheus:
// orders_placed_total{application="order-service",channel="web",} 1834.0
// order_processing_duration_seconds_count{application="order-service",} 1834.0
// order_processing_duration_seconds_sum{application="order-service",} 96.482

Единственное исключение — Gauge: его значение не накапливается вызовами, а вычисляется ленивым коллбэком именно в момент scrape (подробнее в разделе про типы метрик ниже). Всё остальное время между двумя scrape-ами метрики просто живут в памяти JVM как обычные счётчики и не создают сетевой нагрузки сами по себе.

⚠️ @Timed — тоже AOP-прокси, и self-invocation здесь работает так же, как у @Async/@Transactional. Аннотация @Timed сама по себе ничего не делает — её обрабатывает TimedAspect, который нужно явно зарегистрировать бином (@Bean TimedAspect timedAspect(MeterRegistry registry) { return new TimedAspect(registry); }), и который, как любой Spring AOP-аспект, оборачивает вызов прокси. Если метод с @Timed вызывается изнутри того же класса через this.method(), а не через инжектированную ссылку на бин — таймер не сработает, ровно по тем же причинам, что self-invocation ломает @Async/@Retryable/@Transactional.

Типы метрик и когда какой использовать

ТипСемантикаБизнес-пример
CounterМонотонно растущее число, никогда не уменьшаетсяorders_placed_total, payments_declined_total
GaugeТекущее значение, может расти и падать; вычисляется лениво в момент scrape через слабую ссылку на объект-источникactive_shopping_carts, pending_payments_queue_size
TimerОдновременно count + sum + (опционально) гистограмма длительностей событияorder_processing_duration_seconds, payment_gateway_call_duration_seconds
DistributionSummaryКак Timer, но для произвольных числовых величин, не времениorder_total_amount (распределение сумм заказов), items_per_order
LongTaskTimerЧисло и длительность задач, которые выполняются прямо сейчас (в отличие от Timer, который фиксирует уже завершённые)batch_settlement_in_progress — если ночной джоб settlement завис, это видно немедленно, а не после его завершения
// Gauge — типичная ошибка: держать объект живым, чтобы не потерять метрику
class CartMetrics {

    private final ShoppingCartRegistry cartRegistry; // сильная ссылка держит Gauge живым

    CartMetrics(MeterRegistry registry, ShoppingCartRegistry cartRegistry) {
        this.cartRegistry = cartRegistry;
        Gauge.builder("active_shopping_carts", cartRegistry, ShoppingCartRegistry::size)
                .description("Текущее число незавершённых корзин")
                .register(registry);
    }
}
🚨 Gauge хранит только слабую ссылку на объект-источник значения. Gauge.builder(name, stateObject, valueFunction) не удерживает stateObject от сборки мусора — это осознанное решение Micrometer, чтобы метрики не создавали утечек памяти. Но если нигде в приложении, кроме локальной переменной внутри конструктора, нет сильной ссылки на этот объект — он будет собран GC, и Gauge молча начнёт возвращать 0 (или NaN) без единой ошибки в логах. Всегда держите объект-источник Gauge полем компонента (как cartRegistry выше), а не временной переменной.

Теги и продвинутая настройка: MeterFilter, гистограммы, MeterBinder

Теги (dimensions) превращают одну метрику в семейство временных рядов, которые можно фильтровать и группировать в PromQL — например, payments_total с тегами status и gateway позволяет построить и общий график, и разбивку по провайдеру:

@Component
class PaymentMetrics {

    private final MeterRegistry registry;

    void recordPayment(PaymentResult result) {
        Counter.builder("payments_total")
                .tag("status", result.status().name())      // SUCCESS | DECLINED | TIMEOUT — ограниченный набор
                .tag("gateway", result.gatewayName())          // stripe | adyen — тоже ограниченный набор
                .register(registry)
                .increment();
    }
}

Для гистограмм длительностей и перцентилей Prometheus строит перцентили сам, по бакетам, которые нужно явно запросить у Micrometer (по умолчанию Timer хранит только count/sum, без бакетов):

@Bean
MeterFilter orderProcessingHistogram() {
    return new MeterFilter() {
        @Override
        public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
            if (id.getName().equals("order_processing_duration_seconds")) {
                return DistributionStatisticConfig.builder()
                        .percentilesHistogram(true)                    // бакеты для histogram_quantile() в PromQL
                        .serviceLevelObjectives(0.5, 1.0, 2.0, 5.0) // фиксированные SLO-границы в секундах
                        .build()
                        .merge(config);
            }
            return config;
        }
    };
}

Тот же MeterFilter — центральная точка глобальной политики: можно переименовывать метрики, отбрасывать нежелательные теги или полностью запрещать метрики по маске имени (MeterFilter.deny(...)), не трогая код, который эти метрики регистрирует.

🚨 Неограниченный тег — самая частая причина «Prometheus упал под ночь». Каждая уникальная комбинация значений тегов создаёт отдельный временной ряд в TSDB Prometheus. Тег вида .tag("orderId", order.getId()) или .tag("userId", userId) при миллионах заказов и пользователей создаёт миллионы уникальных рядов (cardinality explosion) — память Prometheus растёт неограниченно, запросы замедляются на порядки, и в худшем случае под с Prometheus падает по OOM, забирая с собой мониторинг всех сервисов в кластере, а не только виновника. Правило: теги — только для значений с заранее известным, ограниченным набором (статусы, названия шлюзов, каналы продаж). Всё уникальное на уровне сущности (id заказа, id пользователя, email) — в логи или трейсинг, никогда не в тег метрики.

Программная регистрация группы метрик, зависящих от внешнего состояния (например, размер очереди из библиотеки, которая сама не умеет в Micrometer) — интерфейс MeterBinder, автоматически подхватываемый автоконфигурацией:

@Component
class PendingSettlementsMetrics implements MeterBinder {

    private final SettlementQueue settlementQueue;

    @Override
    public void bindTo(MeterRegistry registry) {
        Gauge.builder("pending_settlements_queue_size", settlementQueue, SettlementQueue::size)
                .register(registry);
    }
}

Экспозиция в Kubernetes: Prometheus Operator и discovery

В кластере Kubernetes Prometheus почти никогда не настраивают вручную списком адресов — используют Prometheus Operator, который через CRD ServiceMonitor декларативно описывает, какие сервисы и по какому пути скрейпить, и сам поддерживает актуальный список таргетов при масштабировании подов:

# ServiceMonitor — CRD Prometheus Operator, лежит в репозитории сервиса как манифест
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: order-service
  labels:
    release: prometheus # Prometheus Operator ищет ServiceMonitor по этому label-селектору
spec:
  selector:
    matchLabels:
      app: order-service # находит Service с таким же label — а через него поды
  endpoints:
    - port: management     # имя порта в Service, НЕ основной порт приложения
      path: /actuator/prometheus
      interval: 30s

Обратите внимание на отдельный management-порт: практика best practice — не публиковать /actuator/* на том же порте, что и бизнес-API, чтобы эндпоинты вроде /actuator/env или /actuator/heapdump не оказались случайно доступны через публичный LoadBalancer/Ingress:

# application.yml
management:
  server:
    port: 9404  # отдельный порт только для actuator-эндпоинтов
🔑 Что делать без Prometheus Operator. В более простых кластерах, где Prometheus настроен статически (не через Operator), discovery часто делают через аннотации на подах, которые читает kubernetes_sd_config в конфиге самого Prometheus: prometheus.io/scrape: "true", prometheus.io/port: "9404", prometheus.io/path: "/actuator/prometheus". Суть та же — pull-модель и явное указание, где искать метрики, просто без CRD-абстракции.

Grafana: визуализация и алертинг поверх PromQL

Grafana подключается к Prometheus как к источнику данных (datasource) и строит графики через язык запросов PromQL. Ключевая идея — бизнес-метрика, собранная Micrometer, становится не просто числом на дашборде, а базой для агрегаций и производных величин, посчитанных прямо на стороне Prometheus:

# Скорость оформления заказов — заказов в секунду, усреднённо за последние 5 минут
rate(orders_placed_total{application="order-service"}[5m])

# Доля отклонённых платежей за то же окно
sum(rate(payments_total{status="DECLINED"}[5m]))
  /
sum(rate(payments_total[5m]))

# 95-й перцентиль времени обработки заказа — использует бакеты из publishPercentileHistogram()
histogram_quantile(0.95,
  sum(rate(order_processing_duration_seconds_bucket[5m])) by (le)
)

Дашборды в зрелых командах хранят как код в репозитории (JSON-модель дашборда экспортируется из Grafana и провижинится через ConfigMap/Grafana provisioning или Terraform-провайдер), а не редактируют вручную через UI — иначе дашборд расходится с историей изменений сервиса и легко теряется при пересоздании инстанса Grafana.

Алертинг настраивается либо правилами Prometheus (PrometheusRule CRD, обрабатывается Alertmanager), либо unified alerting самой Grafana — в обоих случаях порог задаётся тем же PromQL-выражением, что и график:

# PrometheusRule — алерт "слишком много отклонённых платежей 10 минут подряд"
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
spec:
  groups:
    - name: order-service.rules
      rules:
        - alert: HighPaymentDeclineRate
          expr: |
            sum(rate(payments_total{status="DECLINED"}[5m]))
              /
            sum(rate(payments_total[5m])) > 0.1
          for: 10m
          labels:
            severity: critical
          annotations:
            summary: "Доля отклонённых платежей выше 10% уже 10 минут"
✅ Алерт по бизнес-метрике — часто ценнее алерта по технической. Алерт на 95-й перцентиль latency важен, но алерт на «доля отклонённых платежей выросла» или «заказов оформляется в 3 раза меньше нормы для этого часа» напрямую указывает на потерю денег и почти всегда обнаруживает проблему раньше, чем деградация чисто технических показателей — платёжный шлюз может отвечать быстро (низкий latency) и при этом стабильно отклонять транзакции по бизнес-причине.

Сравнение подходов к сбору метрик

КритерийMicrometer + PrometheusPrometheus client напрямуюOpenTelemetry MetricsDropwizard Metrics
Привязка к вендоруНет — фасад, легко добавить второй backend (Datadog, CloudWatch) без правки бизнес-кодаЖёсткая — API специфичен для PrometheusНет — становится индустриальным стандартом, включает метрики, трейсы и логи в одной моделиЧастичная — свой формат, нужны адаптеры-репортёры под конкретный backend
Интеграция со Spring BootНативная, автоконфигурация "из коробки"Нет — всё рукамиЧерез micrometer-tracing/OTel starter, зреет с каждым релизом BootБыла основной до Micrometer (Spring Boot 1.x), сейчас легаси в контексте Spring
Модель сбораPull (Prometheus сам скрейпит)PullPush (OTLP-экспортёр отправляет наружу) или pull через Prometheus-совместимый exporterОбычно push (репортёры сами шлют наружу по таймеру)
Когда выбиратьДефолт для Spring Boot приложений почти всегдаНужны редкие низкоуровневые возможности клиента, которых нет в фасадеКоманда уже унифицирует трейсинг+метрики+логи через OTel-коллекторТолько поддержка легаси-кода, для новых проектов не стоит начинать с него

Когда применять, когда избегать

Заводить бизнес-метрику стоит, когда:

  • у события есть чёткая семантика на уровне предметной области, а не техническая деталь реализации (заказ оформлен, платёж отклонён, возврат оформлен);
  • по метрике реально будет настроен дашборд или алерт — если метрика никогда никем не смотрится, это просто накладные расходы на память и scrape без пользы;
  • теги метрики образуют заранее известное, ограниченное множество значений (статусы, каналы, типы, названия партнёров/шлюзов).

Избегать стоит, когда:

  • значение по сути уникально на уровне записи (id сущности, email, номер карты) — это тег для трейса или поле лога, не тег метрики (см. danger-врезку про кардинальность выше);
  • нужна полная история конкретного события с контекстом «что именно произошло и почему» — метрика хранит только агрегированное число, для разбора конкретного инцидента нужны структурированные логи или distributed tracing, метрики их не заменяют;
  • вопрос требует ad-hoc аналитики с произвольными срезами (например, «топ-10 клиентов по сумме отменённых заказов за квартал») — Prometheus не предназначен для такой аналитики, это задача для OLAP/BI-системы поверх данных из бизнес-БД или event-стора, не поверх метрик.

Как тестировать

Для unit-тестов Micrometer предоставляет SimpleMeterRegistry — реализацию MeterRegistry, которая держит метрики только в памяти, без какого-либо реального backend, и не требует поднятия Spring-контекста:

class OrderMetricsTest {

    private final MeterRegistry registry = new SimpleMeterRegistry();
    private final OrderMetrics metrics = new OrderMetrics(registry);

    @Test
    void incrementsOrdersPlacedCounterOnEachOrder() {
        metrics.recordOrderPlaced();
        metrics.recordOrderPlaced();

        assertThat(registry.get("orders_placed_total")
                .tag("channel", "web")
                .counter()
                .count())
                .isEqualTo(2.0);
    }

    @Test
    void recordsOrderProcessingDuration() {
        metrics.recordProcessingTime(() -> {
            try { Thread.sleep(10); } catch (InterruptedException ignored) { }
            return null;
        });

        assertThat(registry.get("order_processing_duration_seconds").timer().count())
                .isEqualTo(1);
    }
}

В интеграционных тестах, поднимающих полный контекст, достаточно заинжектить настоящий MeterRegistry (Spring Boot Test автоматически регистрирует SimpleMeterRegistry, если micrometer-registry-prometheus не сконфигурирован в тестовом профиле явно) и после вызова бизнес-операции проверить содержимое так же, как в unit-тесте выше — либо, для полной end-to-end уверенности, дёрнуть сам эндпоинт /actuator/prometheus через TestRestTemplate и проверить, что строка метрики действительно присутствует в exposition-формате.

⚠️ Не тестируйте точные значения Timer по реальному времени выполнения. Ассерты вида «длительность равна X мс» на реальном Timer флаки по своей природе — время выполнения плавает от прогона к прогону. Проверяйте count() (метрика вообще записалась) и, если действительно нужна точная длительность, — используйте Timer.builder(...).clock(mockClock) с подконтрольным Clock вместо системного времени, аналогично тому, как для детерминированных тестов времени используется внедряемый Clock в обычном бизнес-коде.