У типичного Spring Boot приложения из коробки есть технические метрики — CPU, heap, latency HTTP-эндпоинтов, размер пула соединений к БД. Они отвечают на вопрос «здоров ли сервис», но не отвечают на вопросы бизнеса: «сколько заказов оформлено за последний час», «какая доля платежей падает с ошибкой шлюза», «сколько активных корзин прямо сейчас». Без явно собираемых бизнес-метрик эти вопросы решаются одним из двух способов — либо ждут жалобы от клиентов и саппорта, либо инженер вручную пишет SQL-запрос в проде посреди инцидента, теряя время именно тогда, когда оно критично.
Вторая проблема — задержка обнаружения деградации. Технический дашборд может быть зелёным (CPU в норме, HTTP 200, latency в порядке), пока платёжный шлюз молча возвращает успешный HTTP-ответ с бизнес-статусом «отклонено» — сервис технически здоров, но бизнес-процесс сломан. Только метрика уровня предметной области (payments_declined_total) делает такую деградацию видимой на дашборде и позволяет настроить алерт до того, как проблему заметят по выручке.
/actuator/prometheus каждого пода; Grafana строит дашборды и алерты поверх этих временных рядов через PromQL.
Зависимости — 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;
});
}
}
Timer.record(...) можно разметить метод аннотацией @Timed("order_processing_duration_seconds") — таймер соберётся автоматически вокруг вызова через AOP. Это удобно для быстрого старта, но менее гибко: нельзя добавить бизнес-теги, зависящие от результата выполнения (например, status=success|failed), как это легко сделать вручную через Timer.Sample.
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 сама по себе ничего не делает — её обрабатывает 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.builder(name, stateObject, valueFunction) не удерживает stateObject от сборки мусора — это осознанное решение Micrometer, чтобы метрики не создавали утечек памяти. Но если нигде в приложении, кроме локальной переменной внутри конструктора, нет сильной ссылки на этот объект — он будет собран GC, и Gauge молча начнёт возвращать 0 (или NaN) без единой ошибки в логах. Всегда держите объект-источник Gauge полем компонента (как cartRegistry выше), а не временной переменной.
Теги (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(...)), не трогая код, который эти метрики регистрирует.
.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 почти никогда не настраивают вручную списком адресов — используют 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-эндпоинтов
kubernetes_sd_config в конфиге самого Prometheus: prometheus.io/scrape: "true", prometheus.io/port: "9404", prometheus.io/path: "/actuator/prometheus". Суть та же — pull-модель и явное указание, где искать метрики, просто без CRD-абстракции.
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 минут"
| Критерий | Micrometer + Prometheus | Prometheus client напрямую | OpenTelemetry Metrics | Dropwizard Metrics |
|---|---|---|---|---|
| Привязка к вендору | Нет — фасад, легко добавить второй backend (Datadog, CloudWatch) без правки бизнес-кода | Жёсткая — API специфичен для Prometheus | Нет — становится индустриальным стандартом, включает метрики, трейсы и логи в одной модели | Частичная — свой формат, нужны адаптеры-репортёры под конкретный backend |
| Интеграция со Spring Boot | Нативная, автоконфигурация "из коробки" | Нет — всё руками | Через micrometer-tracing/OTel starter, зреет с каждым релизом Boot | Была основной до Micrometer (Spring Boot 1.x), сейчас легаси в контексте Spring |
| Модель сбора | Pull (Prometheus сам скрейпит) | Pull | Push (OTLP-экспортёр отправляет наружу) или pull через Prometheus-совместимый exporter | Обычно push (репортёры сами шлют наружу по таймеру) |
| Когда выбирать | Дефолт для Spring Boot приложений почти всегда | Нужны редкие низкоуровневые возможности клиента, которых нет в фасаде | Команда уже унифицирует трейсинг+метрики+логи через OTel-коллектор | Только поддержка легаси-кода, для новых проектов не стоит начинать с него |
Заводить бизнес-метрику стоит, когда:
Избегать стоит, когда:
Для 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 флаки по своей природе — время выполнения плавает от прогона к прогону. Проверяйте count() (метрика вообще записалась) и, если действительно нужна точная длительность, — используйте Timer.builder(...).clock(mockClock) с подконтрольным Clock вместо системного времени, аналогично тому, как для детерминированных тестов времени используется внедряемый Clock в обычном бизнес-коде.