Observability-as-Code - это подход, при котором базовые элементы наблюдаемости (метрики, логи, трассировки) описываются и проверяются так же строго, как и код самого приложения. Эта информация хранится в VCS и к ней выдвигаются такие же требования, как “остальному коду”. Инженеры явно описывают "контракт" наблюдаемости (какие метрики и логи должна выдавать система, с какими свойствами), снабжают этот контракт автоматическими тестами и включают проверки в процесс CI/CD.
В данной публикации мы подробно разберем, почему такой подход эффективен, и как его реализовать в Spring Boot с использованием Micrometer, OpenTelemetry и инструментов вроде ArchUnit.
Чтобы "контракт наблюдаемости" не превращался в бесконечный список метрик "я так вижу", удобно опираться на общепринятые модели:
Golden Signals (latency, traffic, errors, saturation) - минимальный набор сигналов здоровья user-facing системы;
RED (rate, errors, duration) - практичный срез Golden Signals для каждого входа в сервис (HTTP, gRPC, consumer);
USE (utilization, saturation, errors) - базовая модель для каждого ограничивающего ресурса (CPU/GC, пул потоков, пул соединений, очереди, lag).
Observability-as-Code встречается редко
Сегодня почти все понимают важность наблюдаемости: как минимум три ее столпа - логи, метрики, трейсы. Однако в реальных проектах редко встретишь системный подход, где требования к метрикам и логам формализованы, а их наличие и корректность проверяются автоматически. Причины этого могут быть следующие:
Отсутствие явного контракта. Обычно, метрики и логи добавляются по мере необходимости, без общего "списка обязательных показателей". Как следствие, со временем покрытие наблюдаемости становится неравномерным, а новые разработчики могут не знать, что каждый новый компонент должен добавлять определенные метрики или логи.
Сложность тестирования. В отличие от бизнес-логики, проверить в тесте, что система пишет нужный лог или публикует метрику, сложнее. Многие команды не знают, как удобно эмулировать MeterRegistry или перехватить логи, поэтому ограничиваются ручной проверкой метрик в мониторинге.
Инструменты в основном нацелены на продакшн. Исторически фокус observability-инструментов - сбор и отображение данных в работающей системе (Prometheus, Grafana и т.д.), а не на этапе разработки. "Наблюдаемость как код" подразумевает, что конфигурации дашбордов, алертов и пр. хранятся в репозитории и проходят ревью как код. На данный момент это не самая распространенная практика, и процессы CI/CD многих компаний к этому не готовы.
В современных проектах ценность стабильного контракта наблюдаемости возрастает. Метрики и их теги становятся частью API системы для SRE-команд. Если пропадет ключевая метрика или изменится ее смысл, это подобно поломке контракта между сервисами. Именно поэтому все больше продвинутых команд стремятся описывать и версионировать настройки мониторинга в репозитории наравне с кодом.
Модель метрик и "контракт" наблюдаемости
Чтобы внедрить Observability-as-Code, сначала определим, что входит в контракт наблюдаемости Spring-сервиса.
В первую очередь, рекомендуется опираться на каркас контракта RED + USE
RED - обязателен для каждого "входа" (entry point):
HTTP endpoint / gRPC method / Kafka listener / job.
Мы должны уметь ответить на 3 вопроса: сколько (Rate), насколько часто ломается (Errors), насколько долго (Duration).
USE - обязателен для каждого ресурса, который может ограничить пропускную способность:
thread pool (Tomcat/Netty event loop/пулы), DB connection pool (Hikari), очереди/lag (Kafka), память/GC, диск/сеть.
Мы должны уметь ответить: насколько занят (Utilization), насколько "уперся" и копится очередь (Saturation), какие ошибки дает (Errors).
Правило контракта: RED описывает "сервис как API", USE - "сервис как систему", а вместе они покрывают золотые сигналы (latency/traffic/errors через RED + saturation через USE).
Под моделью метрик мы понимаем набор метрик, трассировок и лог-сообщений, которые сервис обязан генерировать, и требования к ним:
Обязательные метрики - список метрик, которые должны присутствовать во всех сервисах или компонентах данного типа. Один из вариантов, для каждого REST-эндпоинта может требоваться метрика счетчика запросов и таймер времени ответа. Spring Boot Actuator уже дает общую метрику HTTP-запросов (http.server.requests), но в контракт можно включить и кастомные метрики - счетчики доменных операций в (orders.created, orders.failed и т.д.).
Обязательные теги (лейблы) - для каждой метрики контракт может предписывать определенные теги. К примеру, все метрики должны иметь тег service=<name> или каждый счетчик ошибок - тег причины ошибки. Популярная ошибка: делать тэг "path" и класть в него "/products/12345" вместо того, чтобы делать плейсхолдер и класть "/products/{productId}". Эти ошибки “раздувают” БД Prometheus/VictoriaMetrics. Также, если метрика связана с методом или продуктовым сценарием, может быть требование тегировать по имени метода или функциональности.
Инварианты метрик - логические соотношения, которые метрики должны соблюдать. Например: метрика success_count + error_count = total_count, либо error_count никогда не превышает total_count. Еще пример - гистограмма времени ответа не должна иметь значений > N для локальных вызовов и т.п. Эти инварианты можно проверять тестами на уровне метрик.
Обязательные точки логирования - контракт может требовать, чтобы при определенных событиях всегда писался лог. Например: все логи должны быть коррелируемыми (traceId/spanId) и структурированными. Обязательно логируем ошибки и аномалии (ERROR/WARN) с доменным контекстом; "успехи" - только для ключевых переходов состояния и/или с sampling. Логирование "каждого запроса на INFO" лучше вынести в access-логи на периметре (gateway/ingress) или включать выборочно, иначе при высокой нагрузке это превращается в шум и существенную стоимость. Также сюда относятся требования к структуре логов (использование JSON-формата или включение корреляционного ID).
Трассировки - определяем, какие операции должны сопровождаться distributed trace. Для веб-сервиса каждый входящий запрос логически рассматривается как потенциальный "корень" трассировки. Фактически же trace создается только для сэмплированных запросов - стратегия и процент сэмплирования настраиваются (head/tail sampling) и являются частью observability-конфигурации, а не бизнес-контракта. Если трассировка активна, корневой span автоматически оборачивает HTTP-запрос, а downstream-спаны (вызовы БД, внешних API, кэша и т.д.) создаются автоматически либо явно - через Observation API для ключевых доменных этапов. Контракт может требовать наличие кастомных спанов для бизнес-критичных операций, но не гарантирует, что они будут созданы для каждого запроса - только для тех, которые попали в трассировку. Атрибуты спанов доб��вляются с учетом PII и кардинальности: userId/orderId - только при разрешении политиками безопасности и только как high-cardinality атрибуты (не попадающие в метрики). Для метрик используются безопасные low-cardinality признаки: тип операции, результат, класс ошибки, сценарий.
Таким образом, контракт наблюдаемости похож на спецификацию API, только для телеметрии. Он может быть оформлен в виде документа (Markdown/YAML) в репозитории или кода (класс с константами метрик и методами проверки). Главное - этот контракт версионирован и развивается вместе с кодом. Новая фича - значит, вероятно, нужно обновить и описание метрик/логов.
Пример контракта: шаблон метрик для REST-сервисов
Представим упрощенный контракт для REST-сервиса:
HTTP (baseline): используем встроенный
Timerhttp.server.requestsс тегами method/status/uri (uri - route template, не raw path). Он дает count/sum/max (и histogram при включении).Domain (custom): бизнес-метрики называем в dot-нотации (
orders.created,orders.failed,order.processing) и даем только low-cardinality теги (operation/result/error_class), без userId/orderId.Логирование: обязательно - структурированные ERROR/WARN (и ключевые бизнес-события) с traceId/spanId; "Starting/Completed на запрос" - либо access-логи на периметре, либо выборочно (sampling/allowlist).
Трейсы: root span на входящий HTTP-запрос создается автоматически, имя/route должны быть шаблонными (route template).
Многое из этого Spring Boot дает прямо "из коробки" (Actuator metrics, интеграция с OpenTelemetry). При этом Observability-as-Code подразумевает, что мы не полагаемся целиком на автоматику - мы явно утверждаем, какие сигналы должны быть, и проверяем их.
Реализация контракта в Spring Boot
Современный Spring Boot уже содержит встроенную поддержку наблюдаемости, что облегчает реализацию нашего подхода. Рассмотрим, как описанный контракт можно зафиксировать в коде Spring приложения:
Micrometer Metrics: Spring Boot Actuator + Micrometer предоставляют нам API для метрик. Мы можем объявлять нужные метрики через MeterRegistry (или ObservationRegistry, см. ниже). Например, для каждого бизнес-метода инкрементировать счетчик или записывать таймер. Хорошей практикой будет собрать все названия метрик и их теги в одном месте - enum или класс
MetricsContractс константами:
public final class MetricsContract {
public static final String METRIC_REQUEST_COUNT = "api.requests";
public static final String TAG_ENDPOINT = "endpoint";
// ... etc.
}Таким образом, разработчики используют только константы при создании метрик, избегая опечаток. Кроме того, можно предусмотреть утилиты, которые автоматически при создании метрики добавляют стандартные теги (берут имя сервиса из настроек).
OpenTelemetry Tracing: стандартный путь - Micrometer Tracing (бриджи Brave или OpenTelemetry). Стартер spring-boot-starter-opentelemetry, упрощает включение OTLP-экспорта для трейсов/метрик/логов через management. свойства. Он интегрируется с Micrometer Tracing и умеет отправлять трейсы по протоколу OTLP. Это значит, что каждое наблюдение в приложении может автоматически становиться как метрикой, так и спаном трассировки. Spring Boot уже создает наблюдение (observation) для каждого HTTP-запроса, поэтому мы получим базовые трейсы без кода. Но для контрактных внутренних спанов можно вручную использовать API Observation или аннотации.
Аннотации наблюдаемости: Micrometer предоставляет аннотации, например
@Timed,@Counted,@Observed,@NewSpan. Мы можем пометить каждый метод контроллера аннотацией@Timed(value="requests.duration", extraTags={"endpoint", "<name>"})- тогда Micrometer сам будет собирать таймеры и счетчики вызовов метода. Но осторожно: некоторые контроллеры уже автоматом обернуты Observations, и аннотации могут задублировать метрики.Observation API: Альтернативно, в коде можно явно создавать observation. Как один из примеров:
observationRegistry.observationConfig().observationConvention(new MyObservationConvention());
Observation.createNotStarted("processOrder", observationRegistry)
.lowCardinalityKeyValue("orderType","digital")
.highCardinalityKeyValue("userId", userId)
.observe(() -> service.processOrder());Это запишет метрику и создаст span с именем
processOrder, имеющий указанные атрибуты (lowCardinality пойдут и в метрики, high только в трассу). Этот подход удобен для того, чтобы вокруг критических блоков кода самостоятельно ставить "замеры", задавая имена по контракту.
Логирование: Чтобы соблюдать контракт логирования, стоит использовать структурированные логи (эту тему мы также рассматривали в нашей статье Структурное логирование в Spring Boot 3.4). Логгер через Logback + JSON encoder, чтобы каждое событие было JSON с полями (
timestamp,level,message,traceId, ...). Тогда можно требовать, чтобы определенные поля всегда присутствовали. Spring Observability позволяет связывать логи и трассировки - с помощью MDC вставлять текущий trace-id в каждый лог. Можно в конфигурации Logback добавить%X{traceId}в шаблон лога, и тогда каждый лог-сообщение автоматически содержит идентификатор трассировки.
Для ключевых мест, скорее всего, придется явно вызывать логгер. Написать аспект или фильтр, который на входе запроса пишет "Incoming request [endpoint=..., traceId=...] и на выходе - "Completed request ... in ...ms". В простейшем случае можно делать это в каждом контроллере вручную (на уровне template метода/аннотации). Главное - не пропустить новый endpoint. Мы позже увидим, как это контролировать инструментально.
Интеграция с observability-стеком: Наша цель - сгенерированные метрики/логи/трейсы должны попадать в стандартные инструменты: Prometheus/Grafana для метрик, Grafana Loki для логов, Grafana Tempo для трейсов (или Jaeger/Zipkin аналоги). К счастью, выбранные технологии легко интегрируются:
Prometheus: /actuator/prometheus появляется, когда на classpath есть
spring-boot-starter-actuatorиio.micrometer:micrometer-registry-prometheus;сам эндпоинт нужно явно экспонировать (например,management.endpoints.web.exposure.include=prometheus). Альтернатива - использовать OtlpMeterRegistry, отправляя метрики прямо в OpenTelemetry Collector (Grafana Alloy). Alloy - дистрибутив OpenTelemetry Collector от Grafana: он принимает OTLP, обрабатывает сигналы и экспортирует их дальше. Для метрик корректнее говорить так: Alloy может либо отдать metrics endpoint для Prometheus scrape (pull-модель), либо отправлять метрики в remote storage (Mimir) по push-механизмам. Логи обычно уходят в Loki, трейсы - в Tempo.Loki: Логи можно сразу писать в Loki через docker driver (если наше приложение в контейнере) или через OTel Collector (Alloy) настроить прием логов. Spring Boot 4 дает конфигурационные точки для OTLP-логирования, но "из коробки" логи по OTLP не поедут - нужен appender/handler для вашей logging-системы (Logback/Log4j2). Если не хотите тащить экспериментальные/сыроватые OTLP-appenders, самый безопасный путь для продакшена: JSON-логи в stdout + агент/драйвер (promtail / fluent-bit / docker logging driver) до Loki. OTLP-логи включайте осознанно, как часть контракта и инфраструктуры.
Tempo: Для трассировок используем OTLP экспорт из Spring Boot. Boot + Micrometer Tracing с OTel-бриджем экспортирует трейсы по OTLP: по умолчанию через OTLP/HTTP (HTTP exporter), а OTLP/gRPC можно выбрать при необходимости. Дальше - напрямую в Tempo или через Alloy/OTel Collector. Если вы используете Spring Boot 4 + spring-boot-starter-opentelemetry, настраивайте OTLP через application.yaml/properties:
management.opentelemetry.tracing.export.otlp.endpoint=...
management.otlp.metrics.export.url=...
management.opentelemetry.logging.export.otlp.endpoint=...а имя сервиса задавайте через spring.application.name.
Переменные
OTEL_используйте в сценарии javaagent/чистого OpenTelemetry SDK - не смешивайте оба подхода в одном объяснении без оговорки.
Grafana: Grafana служит единым окном, где мы настроим дашборды для метрик и визуализацию трассировок. Grafana может связывать метрики/трейсы/логи: провал из метрики в трейс возможен при включенных exemplars/корреляции, а связь трейс→логи обычно делается по
traceId. Это нужно один раз настроить в стеке (Tempo/Loki/Prometheus/Mimir) и зафиксировать как часть Observability-контракта окружения.
Архитектура наблюдаемости Spring Boot сервиса: приложение публикует метрики через Actuator/Micrometer (собираются Prometheus или OTel-коллектором), логи отправляются в Loki (через драйвер или коллектор), трассы по OTLP в Tempo. Grafana отображает метрики и позволяет переходить к связанным трейсам и логам.
Таким образом, инструментарий есть - основная работа в рамках Observability-as-Code заключается не столько в настройке экспорта, сколько в гарантии, что разработчики не забывают покрывать код метриками и логами согласно контракту. Этого можно достичь с помощью тестов и статических анализаторов.
Тестирование метрик и логов
Наличие хорошо определенного контракта позволяет писать авто-тесты, которые проверяют соблюдение этого контракта. Такие тесты делятся на юнит-тесты, проверяющие отдельные метрики/логи в конкретных классах, и интеграционные, проверяющие работу системы в целом (через Actuator). Рассмотрим техники тестирования:
Unit-тесты для метрик
Для unit-тестирования метрик удобно воспользоваться in-memory реализацией MeterRegistry, которую предоставляет Micrometer - класс SimpleMeterRegistry. В тесте мы можем передать этот registry нашему сервису, вызвать методы, а затем проверить, что в registry появились нужные метрики с правильными значениями. Если мы протестируем метод FooService.foo(), который должен увеличивать счетчик foo.count:
MeterRegistry meterRegistry = new SimpleMeterRegistry();
FooService fooService = new FooService(meterRegistry);
// Act: вызываем метод несколько раз
fooService.foo();
fooService.foo();
fooService.foo();
// Assert: проверяем, что счетчик увеличился на 3
double invocations = meterRegistry.get("foo.count").counter().count();
assertThat(invocations).isEqualTo(3);В этом примере мы программно достаем метрику по имени и убеждаемся, что она равна ожидаемому значению. Такой подход подходит для проверки бизнес-логики метрик: каждое обращение действительно регистрируется.
Micrometer также предлагает специальный модуль micrometer-test с удобными ассертами. Подключив зависимость тестового scope, можно писать проверки вроде MeterRegistryAssert.assertThat(meterRegistry).hasTimerWithName("foo.time") - то есть убедиться, что таймер с таким именем зарегистрирован после выполнения операции.
Важно: если вы пишете @SpringBootTest, где MeterRegistry будет общим для всех тестов, то после каждого теста его надо чистить (meterRegistry.clear()), иначе метрики будут накапливать значение между тестами.
Таким образом, юнит-тесты метрик проверяют локально, что при вызове функций счетчики и другие метрики меняются строго по контракту. Мы можем написать тест на инвариант: вызвать success и error и проверить, что total_count = success+error.
Интеграционные тесты для метрик
На интеграционном уровне (поднимая контекст Spring) можно проверить, что вся необходимая телеметрия доступна "снаружи" так, как ожидается мониторингом. Например:
Проверять наличие метрик через /actuator/metrics/<name> корректно только после smoke-сценария, который гарантированно регистрирует эти метрики (например, 1-2 HTTP вызова + 1 доменная операция). Иначе тест может быть флейки из-за ленивой регистрации meters.
Либо воспользоваться
meterRegistry.getMeters()внутри теста и сравнить набор имен с ожидаемым списком из контракта.Проверить, что значения метрик меняются при настоящих HTTP-запросах. Через
TestRestTemplateвыполнить вызов контроллера, а затем проверить, что у http.server.requests выросcount(Timer) для нужных тегов method/status/uri (uri - route template).Если используются распределенные трейсы, в тесте можно проверить наличие заголовков трассировки. Не проверяйте трассировку по traceparent в HTTP-ответе: сервер не обязан возвращать этот заголовок. Для e2e-проверки поднимайте in-memory exporter (или тестовый OTLP-collector) и проверяйте список сформированных spans. Заголовок traceparent в ответ добавляйте только если это явное требование вашего контракта (через фильтр/интерсептор). Можно также подключить in-memory экспортер OpenTelemetry (OTel SDK с InMemorySpanExporter) и проверить, что после тестового сценария экспортер содержит ожидаемые spans. Этот путь сложнее, но реализуемый.
Для логов интеграционный тест может использовать библиотеку захвата логов. Один из подходов - подключить в тесте кастомный Appender к Logback, который сохраняет события в список. Logback Classic позволяет программно добавить ListAppender<ILoggingEvent> на нужный логгер. В тесте мы можем выполнить действие и затем проверить список appender.list на наличие сообщений с нужным текстом или уровнем.
Пример: проверим, что при обработке заказа логируется ошибка, если произошел эксепшн:
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(OrderService.class))
.addAppender(appender);
// вызов, провоцирующий ошибку
try { orderService.process(null); } catch(Exception e) { /* ignore */ }
// теперь проверить, что в appender поймалось как минимум одно ERROR-сообщение с словом "Exception"
boolean errorLogged = appender.list.stream().anyMatch(event ->
event.getLevel() == Level.ERROR && event.getFormattedMessage().contains("Exception while processing order"));
assertTrue(errorLogged);Такой тест гарантирует, что в коде не забыли залогировать исключение. Аналогично можно проверять наличие обязательных полей: traceId присутствует в MDC и попал в сообще��ие (это сложнее без полного end-to-end, но можно эмулировать MDC).
Совет: Для удобства можно написать util-классы для тестов, которые инкапсулируют проверку контракта. Метод ObservabilityTestHelper.assertMetricsPresent(MeterRegistry, Set<String> requiredMetrics) или LogTestHelper.expectLog(Level.INFO, "Completed.*"). Тогда при добавлении новой обязательной метрики разработчик поправит один список в helper’e и тесты сразу начнут требовать ее везде.
Архитектурные проверки: ArchUnit
Кроме runtime-тестов, мощным подспорьем могут стать статические проверки на уровне кода. Библиотека ArchUnit позволяет анализировать байткод Java и проверять различные правила архитектуры с помощью unit-тестов. С их помощью мы можем автоматически ловить случаи, когда разработчик добавил новый код, но забыл соблюсти контракт наблюдаемости.
Отслеживание новых эндпоинтов без метрик/логов
Главный риск - появление нового REST-эндпоинта или бизнес-метода без должного инструментирования. Не формулируйте контракт как "в каждом контроллере обязательны INFO-логи и кастомные метрики": HTTP-метрики/трейсы уже покрываются авто-инструментацией. Контракт лучше закреплять на доменных use-case/service методах: там обязательны бизнес-метрики/спаны и структурированные ERROR/WARN, а web-слой оставляем на стандартных http.server.requests и server spans.
ArchUnit для логов: можно написать ArchUnit-правило, которое ищет вызовы логгера без контекста. Пример из практики - проверка, что все logger.info вызываются с параметрами (контекстом). Ниже адаптированный пример: запретить вызовы
Logger.info(String)без параметров:
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.slf4j.Logger;
import static com.tngtech.archunit.base.DescribedPredicate.describe;
import static com.tngtech.archunit.core.domain.JavaCall.Predicates.target;
import static com.tngtech.archunit.lang.conditions.ArchConditions.callMethodWhere;
import static com.tngtech.archunit.lang.conditions.ArchPredicates.is;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
public class LoggingRules {
@ArchTest
static final ArchRule noInfoLoggingWithoutContext =
noClasses().should(callMethodWhere(target(is(describe("logger.info without context",
t -> t.getOwner().isAssignableTo(Logger.class)
&& t.getName().equals("info")
&& t.getRawParameterTypes().size() < 2
)))));
}
Это правило пройдет, только если все вызовы info имеют хотя бы два параметра (сообщение и один аргумент). В нашем случае мы могли бы конкретизировать: требовать, чтобы в сообщении был placeholder и хотя бы один аргумент (что означает наличие контекста). Аналогично можно запретить
logger.error(String msg)без исключения. Такие правила гарантируют, что разработчики пишут информативные логи с контекстом (ID сущностей, и т.д.).
ArchUnit для метрик: Отслеживать использование метрик сложнее, но можно придумать правила. Правило: класс контроллера (аннотирован
@RestController) должен либо вызыватьMeterRegistry, либо быть аннотирован@Timed/@Observed. ArchUnit умеет проверять наличие аннотации у классов и методов. Если у нас контракт, что все эндпоинты должны иметь аннотацию@Observed, то простейшее правило:
classes().that().areAnnotatedWith(RestController.class)
.should().beAnnotatedWith(Observed.class);Хотя на практике это слишком строго и не гибко (некоторые могут предпочесть метрики вызывать в сервисном слое).
В итоге ArchUnit - более прямой способ закодировать проверки. Они выполняются как обычные unit-тесты и падают, если нарушено правило. Рекомендуется сопровождать такие тесты пояснением (ссылкой на ADR или раздел README), почему правило важно. Тогда разработчику будет понятно, что тест упал, потому что "Новый endpoint X не логгирует событие начала/конца - добавьте лог во исполнение контракта наблюдаемости".
Примеры проверок контракта
Что можно автоматизировать:
Полнота метрик: Тест, проверяющий, что список метрик в
meterRegistryсодержит все имена из спискаMetricsContract.REQUIRED_METRICS. Если чего-то нет - либо метрику забыли зарегистрировать, либо ошиблись в имени. Этот тест должен выполняться после короткого smoke-сценария (минимальный набор вызовов/операций), потому что часть метрик регистрируется лениво - только при первом реальном использовании.Запрет изменений метрик без явного обновления контракта: Кстати, вышеупомянутый тест также ловит ситуацию, когда разработчик переименовал метрику или удалил - тогда тест упадет. Чтобы тест не падал, нужно обновить список
REQUIRED_METRICS- это и есть "обновление контракта". Таким образом, любой MR с изменением метрик заставит изменить и контракт, иначе pipeline красный.Отслеживание новых endpoint’ов: Можно автоматически получать список URL, обслуживаемых приложением (через
WebApplicationContext.getBean(RequestMappingHandlerMapping)- он выдаст все mappings). Для каждого URL проверить, что либо его контроллер/метод помечен@Observed/@Timed, либо он вызывает metric/log. Это сложнее, но даже простая эвристика - хотя бы наличие@Observed- уже дисциплинирует.Архитектурные инварианты: Проверки ArchUnit, как в примерах, можно включить: финальность логгеров, наличие контекста в логах, отсутствие вызовов
System.outвместо логгера, и т.д. Эти вещи опциональны, но улучшают качество наблюдаемости.
Подобные проверки следует запускать в CI. Обычно это просто часть mvn test. Если какая-то из них падает, сборка провалится, и merge request не пройдет. Таким образом достигается требуемое: "MR без обновления observability-контракта - FAIL". Команда начнет воспринимать изменения в наблюдаемости всерьез и сопровождать их нужными правками в коде и документации.
Встраивание в CI/CD и процесс разработки
Когда инфраструктура Observability-as-Code настроена, важно интегрировать ее в ежедневный процесс:
CI/CD pipeline: В конвейер сборки (GitLab CI) включаются шаги запуска тестов. Здесь нет ничего особенного, просто все вышеописанные тесты (юнит, интеграционные, ArchUnit) должны выполняться. При желании можно сделать отдельную джобу, например observability-check, которая будет явно запускать только эти проверки, и публиковать отчет (но достаточно и общей сборки).
Code Review: Полезно описать в CONTRIBUTING, что любой значимый код, влияющий на метрики или логи, должен сопровождаться соответствующими изменениями в контракте и тестах. Ревьюеры кода могут использовать чек-лист: "Добавил новый публичный метод -> а где метрика? Добавил новый ключевой WARN-лог -> а тест на него есть?". Со временем разработчики привыкают добавлять наблюдаемость сразу.
Версионирование контракта: Контракт (документ Markdown с перечнем всех метрик и логов) может поддерживаться вручную и обновляться в каждом MR. Однако это легко забыть. Лучше, когда сам код (списки констант, тесты) выступают живой документацией. Можно сгенерировать документацию автоматически: у нас есть enum всех метрик - написать тест, который выкачивает /actuator/metrics и сверяет, что для каждой метрики из enum есть описание (Spring Actuator позволяет задавать описание метрик через
MeterRegistry.config().meterFilter(...)). Если описание отсутствует - тест напомнит дописать. Эту документацию можно публиковать артефактом.Мониторинг самого контракта: Интересная идея - метрики на сами метрики! Мы можем завести счетчик "количество метрик в системе" и отслеживать его по времени. Резкое снижение будет сигналом, что кто-то отключил значимую метрику. Или метрика "покрытие контрактом" - процент endpoint’ов, имеющих логирование (эти вещи можно вычислять и отправлять как спецметрику при старте приложения).
В итоге CI/CD, тесты и ревью образуют Quality Gate для наблюдаемости. Этот подход схож с тем, как мы защищаем другие аспекты качества (без новых юнит-тестов код не пройдет, без обновления OpenAPI спеки MR не мерджится и т.д.). Практика крупной компании показывает: "когда observability-требования автоматизированы, они перестают игнорироваться" - разработчики начинают проектировать фичи с учетом мониторинга изначально, то есть по сути Shift-Left Observability.
Лучшие практики и выводы
Observability-as-Code - это следующий шаг в эволюции DevOps-культуры, сближающий разработку и эксплуатацию. Подведем ключевые рекомендации для внедрения такого подхода в Spring-проектах:
Явно определите контракт наблюдаемости. Сформулируйте, какие метрики, логи и трейсы жизненно важны. Оформите это либо документом, либо кодом (классом с перечислением метрик). Так команда будет иметь общий язык и понимание, что считается "хорошо наблюдаемым сервисом".
Используйте возможности Spring Boot по максимуму. Включите Spring Actuator, Micrometer, Observability аннотации. Больше половины работы (HTTP метрики, базовые трейсы) сделается автоматически. Остальное достраивайте поверх (к примеру, бизнес-метрики через
MeterRegistry.counter(...).increment()в нужных местах).Пишите тесты на метрики и логи. Это необычно, но как мы показали - реализуемо. Unit-тесты с SimpleMeterRegistry быстры и ловят регрессии метрик. Логирование можно тестировать через добавление кастомного Appender. Эти тесты не только ловят забытые метрики, но и документируют, какие значения должны расти при тех или иных действиях.
Внедрите архитектурные проверки. ArchUnit - простой в освоении инструмент, чтобы запретить анти-паттерны ("логгеры должны быть private final" или "не log.error без stacktrace"). Также можно отслеживать слои приложения: требовать, чтобы класс в слое контроллеров не вызывал репозиторий напрямую (это уже про архитектуру, но косвенно влияет и на наблюдаемость, соблюдение структурных constraints).
Интегрируйтесь с мониторингом как с кодом. Не забудьте про обратную сторону: не только само приложение, но и окружение мониторинга должно быть управляемо кодом. Дашборды Grafana, оповещения Prometheus - все это можно хранить в Git (JSON для Grafana, правила алертов в коде). Grafana предоставляет возможности Observability as Code для своих конфигураций. Это поможет синхронизировать изменения: новая метрика - сразу PR с обновлением дашборда, и они выкатываются одновременно.
Обучайте команду и отслеживайте метрики процесса. Вводя новые проверки, убедитесь, что команда понимает их смысл. Хорошо провести мастер-класс по Micrometer, объяснить, как влияют теги на кардинальность метрик, как правильно логировать без излишней болтовни. Также следите за "метриками наблюдаемости" - процент покрытых логированием потоков, среднее число метрик на сервис. Это мета-метрики, но они покажут прогресс в культуре.
В итоге, реализуя Observability-as-Code, вы достигаете того, что наблюдаемость перестает быть чем-то из разряда “неплохо было бы”. Она становится встроенной в разработку: при проектировании API мы сразу думаем, какие метрики нужны, при написании функции сразу вставляем счетчики/логи, а CI не даст забыть о важном. Это особенно важно для senior/lead разработчиков и архитекторов, которые отвечают за надежность системы, так как контракт наблюдаемости становится для них инструментом управления качеством.
И хотя такой подход пока еще редок, со временем он может стать таким же обыденным, как сейчас Continuous Integration. Ведь система, которую легко наблюдать и отслеживать, заведомо более надежна и предсказуема в эксплуатации. Мы не можем контролировать то, что не измеряем - а Observability-as-Code гарантирует, что вы измеряете все, что нужно, и делаете это системно и автоматизированно.
