---
title: "Мониторинг и health-check: Prometheus, /healthz, /readyz и логи в проде"
description: "мониторинг echo: метрики Prometheus через echo-contrib, /healthz и /readyz для Kubernetes, кардинальность ярлыков, slog в проде и тревоги по 5xx."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/23-monitoring/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Мониторинг и health-check: Prometheus, /healthz, /readyz и логи в проде

*Курс «Echo: минималистичный быстрый фреймворк», урок 23 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/23-monitoring/*

мониторинг echo — урок, где обещание двадцать второго сбыточно: замеры, которые вы делали руками через бенчмарк e.ServeHTTP, становятся счётчиками, которые собирает Prometheus, — а /healthz и /readyz скажут платформе, жив ли процесс и готов ли он принимать трафик. Приложение из [двадцать первого урока](/courses/echo/lessons/21-struktura/) разложено по слоям, прод-режим из [двадцать второго](/courses/echo/lessons/22-performance/) уже прячет баннер и урезает лог-уровень, echo-contrib, упоминавшийся ещё в [первом уроке](/courses/echo/lessons/01-znakomstvo/) рядом с сессиями и метриками, — наконец открывается по-настоящему: подключаем официальный пакет echoprometheus, разбираем, что он регистрирует и с какими ярлыками, ставим /metrics под контроль кардинальности, разводим liveness и readiness, настраиваем пробы Kubernetes и переводим журнал канона в slog-JSON, пригодный для сбора в проде. Мониторинг echo — это не дашборды ради дашбордов: это три вопроса «жив ли, готов ли, как себя чувствует» — и честные числа вместо интуиции. Приступаем — наблюдаемость echo начинается с одного middleware и двух маленьких эндпоинтов.

### мониторинг echo: счётчики вместо ручных замеров

Тема «мониторинг echo» начинается там, где закончился двадцать второй урок: вы умеете измерять производительность в цикле бенчмарка и снимать профиль pprof — но это ручные сессии на вашей машине, пока приложение в проде работает вслепую. Разница между «оптимизировали» и «наблюдаем» проста: замер конечен и выполняется людьми, мониторинг непрерывен и выполняется машиной. Платформа раз в несколько секунд опрашивает ваш процесс по трем каналам: метрики — числа, которые процесс отдаёт на /metrics; health-пробы — короткие эндпоинты, по которым решается судьба пода; логи — поток событий, который складывается и ищется после инцидента. Все три канала echo закрывает без экзотики: метрики — официальным contrib-пакетом, пробы — двумя обычными хендлерами, логи — Logger-middleware из [седьмого урока](/courses/echo/lessons/07-vstroennye-middleware/) и slog из [девятого](/courses/echo/lessons/09-logirovanie/).

Отдельно проговорим, зачем это API-серверу на :1323, который пока не «боевой»: навык один и тот же. Если канон не умеет отвечать на вопрос «сколько запросов обработал за минуту и с какими кодами», то и настоящий прод не умеет — вы просто не будете знать о проблемах до письма от пользователя. Наблюдаемость echo строится слоями ровно как слои приложения из двадцать первого урока: сначала факт (счётчики), потом состояние (пробы), потом история (логи) — и только затем тревоги и графики.

### Модель Prometheus: pull, экспозиция и четыре типа метрик

Prometheus — система мониторинга с pull-моделью: сервер сам ходит по настроенным адресам (targets) с интервалом scrape_interval — обычно 15 секунд — и читает ответ в текстовом формате экспозиции. Ваше приложение ничего никуда не отправляет: оно лишь отвечает на GET /metrics плоским текстом вида «имя_метрики{ярлыки} значение». Такая инверсия выгодна и для Go: приложение не тащит очередь отправки, а смерть процесса видна мгновенно — целевой адрес перестал отвечать, в Prometheus появляется метрика up со значением 0.

В экспозиции живут четыре типа метрик, и выбор типа — главное решение, которое вы принимаете как автор. Counter — счётчик, который только растёт (обработанные запросы, созданные задачи); после рестарта процесса он честно падает в ноль, поэтому в запросах его берут через rate() — скорость роста, а не абсолют. Gauge — измеритель, который ходит туда-сюда: температура, глубина очереди, число выполняющихся запросов прямо сейчас. Histogram — гистограмма: наблюдения (например, длительность запроса) раскладываются по заранее объявленным корзинам (buckets), а отдаётся три серии — _bucket, _sum и _count; из корзин в PromQL считается перцентиль функцией histogram_quantile. Summary — альтернатива гистограммы с клиентскими квантилями, которая не умеет агрегироваться между инстансами; для HTTP-сервера почти всегда выбирают histogram. Запомните эти четыре слова — дальше пакет echo-contrib регистрирует за вас ровно счётчик и три гистограммы.

### Как выглядит /metrics на живом каноне

```go
# HELP echo_requests_total How many HTTP requests processed, partitioned by status code and HTTP method.
# TYPE echo_requests_total counter
echo_requests_total{code="200",host="localhost:1323",method="GET",url="/api/v1/tasks"} 42
echo_requests_total{code="404",host="localhost:1323",method="GET",url="/api/v1/nope"} 1
# HELP echo_request_duration_seconds The HTTP request latencies in seconds.
# TYPE echo_request_duration_seconds histogram
echo_request_duration_seconds_bucket{code="200",...,url="/api/v1/tasks",le="0.01"} 38
echo_request_duration_seconds_bucket{code="200",...,url="/api/v1/tasks",le="+Inf"} 42
echo_request_duration_seconds_sum{code="200",...,url="/api/v1/tasks"} 1.732
echo_request_duration_seconds_count{code="200",...,url="/api/v1/tasks"} 42
```

Строки # HELP и # TYPE — самодокументирование: имя метрики, человекочитаемое описание и тип. Далее — образцы (samples): у счётчика одно значение на комбинацию ярлыков, у гистограммы — по строке на корзину с ярлыком le («less or equal»), плюс _sum и _count. Обратите внимание на url="/api/v1/tasks": это шаблон маршрута, а не конкретный адрес конкретного запроса — на этом сходстве держится вся аккуратность наблюдаемости echo, и ниже мы покажем, что бывает, когда шаблона нет.

### echo-contrib/echoprometheus: два конструктора — и это всё

Организация Labstack держит репозиторий echo-contrib, и в нём за метрики отвечает пакет github.com/labstack/echo-contrib/echoprometheus. Внутри echo-contrib он соседствует с сессиями и pprof — то, что первый урок перечислял списком, теперь работает на вас. API пакета специально крошечный: echoprometheus.NewMiddleware("tasksapp") возвращает echo.MiddlewareFunc, который после каждого запроса увеличивает счётчики и заполняет гистограммы; echoprometheus.NewHandler() возвращает echo.HandlerFunc, который собирает реестр и отдаёт его в формате экспозиции. Каноничная связка из README пакета — ровно две строки: e.Use(echoprometheus.NewMiddleware("tasksapp")) и e.GET("/metrics", echoprometheus.NewHandler()).

Две честности по исходникам. Первая: в echo-contrib живёт и старый пакет prometheus с конструктором NewPrometheus — в исходниках он помечен «Deprecated: use echoprometheus package instead», новые проекты пишут echoprometheus. Вторая: по умолчанию middleware регистрирует свои сборщики в prometheus.DefaultRegisterer, а хендлер читает prometheus.DefaultGatherer — то есть вашими бизнес-метриками из promauto делить один и тот же /metrics можно без настроек (покажем ниже). Конфиги расширяют поведение: MiddlewareConfig умеет Namespace и Subsystem (второй по умолчанию "echo" — потому и имена echo_requests_total), Skipper, кастомные ярлыки через LabelFuncs и обратные вызовы BeforeNext/AfterNext — в них удобно инкрементировать собственные счётчики.

### Канон: подключаем метрики в конструктор приложения

```go
// internal/app/app.go — продолжение сборки из двадцать второго урока
import (
        "github.com/labstack/echo-contrib/echoprometheus"
)

func New(store service.TaskStore, secret []byte, prod bool) *echo.Echo {
        e := echo.New()
        if prod {
                e.HideBanner = true
                e.HidePort = true
                e.Logger.SetLevel(log.INFO)
        }
        e.Use(middleware.RequestID())
        e.Use(middleware.Logger())
        e.Use(middleware.Recover())
        e.Use(echoprometheus.NewMiddleware("tasksapp")) // счётчики и гистограммы
        e.GET("/metrics", echoprometheus.NewHandler())  // вне /api/v1: без JWT

        // ... маршруты, ErrorHandler из двадцать первого урока
        return e
}
```

Порядок подключения: после Logger и Recover — так в замер попадает и цена логирования, а паника, пойманная Recover, успевает засчитаться как 500-й запрос. Маршрут /metrics регистрируется прямо на e, а не на группе /api/v1 из [двадцать первого урока](/courses/echo/lessons/21-struktura/): JWT-гвард не должен закрывать метрики — скрейпер не носит access-токен. Отсюда же правило защиты: /metrics отдаётся без аутентификации, поэтому наружу его не пускают сетью — ниже отдельный danger-блок. Если у вас несколько echo-приложений в одном процессе — не так: NewMiddleware регистрирует сборщики один раз, вторая регистрация того же реестра упадёт с ошибкой AlreadyRegisteredError.

### Проверьте себя: кто отдаёт /metrics

**Вопрос:** Вы вызвали echoprometheus.NewHandler() без конфига. Реестр какого сборщика он собирает в ответе?

- Каждый вызов NewHandler создаёт собственный пустой реестр
- Ничего: NewHandler нужно передать реестр позиционным аргументом
- ✅ prometheus.DefaultGatherer — тот же реестр по умолчанию, куда пишет и NewMiddleware
- Реестр, который отдаёт только бизнес-метрики, но не echo_requests_total

По исходнику NewHandler() — это NewHandlerWithConfig(HandlerConfig{}), а в нём «Gatherer sets ... Defaults to: prometheus.DefaultGatherer». NewMiddleware тоже пишет в DefaultRegisterer по умолчанию. Пара по умолчанию работает без единой строки конфигурации — и в тот же реестр попадают ваши promauto-счётчики.

### Что именно считает middleware: ярлыки, корзины и ловушка 404-х

echoprometheus регистрирует четыре метрики: счётчик requests_total с описанием «How many HTTP requests processed, partitioned by status code and HTTP method» и три гистограммы — request_duration_seconds («The HTTP request latencies in seconds»), request_size_bytes и response_size_bytes. Все четыре несут одинаковый набор ярлыков из исходника: code, method, host, url. Корзины гистограммы длительности — prometheus.DefBuckets, то есть .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5 и 10 секунд: хватит для API, чьи ответы должны занимать миллисекунды, и бессмысленно для долгоиграющих стримов. Размеры измеряются по корзинам от 1 КБ до 10 МБ — те самые лимиты, о которых [урок про формы и файлы](/courses/echo/lessons/13-formy-fajly/) держал под контролем через BodyLimit.

Самый ценный ярлык — url: по умолчанию middleware берёт c.Path() — шаблон маршрута вроде /api/v1/tasks/:id — поэтому миллион разных идентификаторов схлопывается в одну временную серию. Но у роутера из [третьего урока](/courses/echo/lessons/03-marshrutizaciya/) есть честная слепая зона: когда маршрут не нашёлся вовсе, c.Path() пуст, и в ярлык уезжает фактический путь запроса. Каждый сканер уязвимостей, стучащийся по случайным адресам /admin, /wp-login.php, /.env, создаёт новую серию — это называется взрывом кардинальности: память процесса и Prometheus растут на мусорных ярлыках. В MiddlewareConfig на этот случай есть флаг DoNotUseRequestPathFor404 — все несопоставленные запросы получают общий ярлык, — а также LabelFuncs, позволяющий переопределить url и host или добавить свой ярлык. Стандартный здравый выбор для прода: 404-е считаем без различия по путям, а авторские ярлыки ограничиваем дискретными значениями (версия API, регион), не идентификаторами.

### Проверьте себя: кардинальность

**Вопрос:** Команда хочет ярлык user_id на echo_requests_total, чтобы считать запросы каждого пользователя. Почему это почти всегда ошибка?

- Prometheus не поддерживает строковые ярлыки, только числа
- ✅ На каждое уникальное значение ярлыка Prometheus держит отдельную серию: время жизни пользователей × коды × методы — серии размножаются непредсказуемо
- Счётчики запрещено размечать ярлыками по спецификации формата экспозиции
- Ничего страшного: user_id — лучший ярлык, потому что он уникален

Ярлык — часть имени серии, и каждая новая комбинация значений — новая серия навсегда (в lifetime процесса). Дискретные значения вроде метода и кода — да; идентификаторы пользователей, задач и сырые пути — дорога к исчерпанию памяти. Для «кто именно» есть логи с request_id из RequestID-middleware, а не метрики.

### Что выведет счётчик на сами скрейпы

```go
e.Use(echoprometheus.NewMiddleware("tasksapp"))
e.GET("/metrics", echoprometheus.NewHandler())
// Prometheus скрейпит http://localhost:1323/metrics каждые 15 секунд
// Сколько echo_requests_total прибавится за минуту из-за самих скрейпов?
```

**Что выведет программа?**

- 0: маршрут /metrics всегда пропускается сборщиком
- 1 за весь процесс: первый скрейп, дальше кэш
- Зависит от scrape_interval: при 15с счётчик не растёт вообще
- ✅ 4: middleware не делает исключений для /metrics — каждый скрейп считается как запрос

В исходнике прямо написано: «NB: we do not skip metrics handler path by default. This can be added with custom Skipper» — по умолчанию запросы к /metrics измеряются наравне со всеми: +1 скрейп каждые 15 секунд = 4 в минуту с ярлыками code="200", url="/metrics". Хотите чистые числа — добавьте Skipper, как в README пакета: c.Path() == "/metrics".

### Бизнес-метрики канона: задачник считает свои события

Четыре HTTP-метрики отвечают на вопрос «как движок себя чувствует», но не отвечают «сколько задач создали за час» — это уровень сервиса, а не транспорта, и потому живёт в том же слое, что правила из двадцать первого урока. Клиентская библиотека prometheus (она уже в go.mod транзитивно через echo-contrib) даёт промавто-хелперы: promauto.NewCounter регистрирует счётчик в реестре по умолчанию сразу при объявлении — и раз NewHandler по умолчанию читает тот же реестр, ваша метрика появляется на /metrics без единой дополнительной строки. Имя собирают по конвенции <приложение>_<объект>_<единица>_total: tasksapp_tasks_created_total. Горячее место инкремента — хендлер после успешного ответа: бизнес-факт засчитан только когда сервис подтвердил, а конверт ошибок из двадцать первого урока гарантирует, что до записи доходит лишь успех. Для сценариев с разложением по исходам (создано / отклонено лимитом ErrLimitReached / конфликт) берут CounterVec с ярлыком outcome — дискретным, а потому безопасным для кардинальности.

Разделение труда держите строгим: метрики — числа для графиков и тревог (счётчики, гистограммы, гейджи), логи — события с контекстом (request_id, user_id, длина запроса). Пытаться посчитать «всё» в метриках — путь к взрыву кардинальности из предыдущего квиза; пытаться строить графики по логам — путь к запросам, которые никто не ждёт.

### Канон: счётчик созданных задач

```go
// internal/handler/tasks.go — рядом с фабриками хендлеров
var tasksCreated = promauto.NewCounter(prometheus.CounterOpts{
        Name: "tasksapp_tasks_created_total",
        Help: "Tasks created via POST /api/v1/tasks",
})

func makeCreateTask(svc *service.TaskService) echo.HandlerFunc {
        return func(c echo.Context) error {
                var in CreateTaskInput
                if err := c.Bind(&in); err != nil {
                        return echo.NewHTTPError(http.StatusBadRequest)
                }
                t, err := svc.Create(in.Title)
                if err != nil {
                        return err // домен → convert из двадцать первого урока
                }
                tasksCreated.Inc() // успех засчитан только после 201
                return c.JSON(http.StatusCreated, t)
        }
}
```

promauto.NewCounter регистрируется немедленно; второй запуск с тем же именем упадёт с AlreadyRegisteredError («duplicate metrics collector registration attempted») — поэтому объявление живёт в пакете один раз, а не в конструкторе на каждый вызов. Инкремент — после svc.Create и до ответа 201: если сервис вернул ErrLimitReached, счётчик не растёт, а разложение по исходам удобно добавить CounterVec-ом с ярлыком outcome={created,limit}. Проверьте появление метрики тем же curl на /metrics — строки HELP/TYPE/знаковый образец появляются без рестарта реестра, но не раньше первого Inc().

### Что увидит балансировщик от readyz с ошибкой

```go
// /readyz: прогрев завершён, но зависимость не отвечает
e.GET("/readyz", func(c echo.Context) error {
        if !storage.Ping(c.Request().Context()) {
                return echo.NewHTTPError(http.StatusServiceUnavailable, "not ready")
        }
        return c.NoContent(http.StatusOK)
})
// ErrorHandler — convert из двадцать первого урока
```

**Что выведет программа?**

- 500: convert переводит любую ошибку в generic-конверт
- ✅ 503: convert пропускает *echo.HTTPError как есть и не трогает его таблицей ошибок
- 200: NoContent вызывается в любом случае
- 404: нечисловой путь /readyz парсится как :id

В convert из двадцать первого урока ветка «he, ok := err.(*echo.HTTPError)» пропускает уже-HTTP-ошибки без изменений — таблица errors.Is применяется только к доменным ошибкам вроде ErrNotFound. Для /readyz это ровно нужный контракт: статус 503 доходит до платформы без пересборки, а текст «not ready» попадает в конверт. Если бы вы вернули raw-ошибку вида errors.New("not ready"), convert искал бы её в errorMap, не нашёл бы и ответил 500 — для пробы это ложный сигнал смерти.

### healthz и readyz: два вопроса платформы

health check go-сервиса в Kubernetes (и в любом честном балансировщике) — это два разных вопроса, и путать их дороже, чем кажется. Liveness-проба отвечает на вопрос «жив ли процесс»: если она падает, платформа перезапускает контейнер. Readiness-проба отвечает на вопрос «готов ли процесс принимать трафик»: если она падает, трафик в под перестаёт попадать — но контейнер не рестартует, процесс продолжает работать и лечиться. Отсюда прямое правило контента: liveness должна быть дешёвой и без внешних зависимостей — процесс, чья база лежит, жив и не должен перезапускаться по кругу, ему нужна пауза без трафика. Readiness наоборот — проверяет то, без чего сервер бесполезен: подключение к хранилищу, прогрев кэша, завершение миграций.

В echo эти пробы — два обычных маршрута, и это лучшая новость урока. Регистрируйте их на верхнем уровне, вне группы /api/v1 и вне JWT: проба не носит токен. Ответы держите минимальными — c.NoContent(http.StatusOK) для успеха и echo.NewHTTPError(http.StatusServiceUnavailable, ...) для отказа: телу пробы нечего сказать, статус всё решает. Для канона на памяти Memory без внешней зависимости разница между пробами условна — но именно поэтому шаблон выше показывает типовой хук Ping/atomic.Bool: проект вырастет до Postgres из реального прода, а маршруты останутся теми же. Полезно помнить и третьего участника: startupProbe даёт медленно стартующему процессу время до включения liveness — без неё перезапуски случаются в первые секунды, когда приложение ещё греет кэш.

### Канон: обе пробы в десять строк

```go
// internal/app/app.go — регистрация рядом с /metrics
var ready atomic.Bool // ещё false при старте процесса

e.GET("/healthz", func(c echo.Context) error {
        return c.NoContent(http.StatusOK) // процесс жив — всегда дешёвый 200
})

e.GET("/readyz", func(c echo.Context) error {
        if !ready.Load() {                       // прогрев не завершён
                return echo.NewHTTPError(http.StatusServiceUnavailable, "warming up")
        }
        if err := store.Ping(c.Request().Context()); err != nil { // зависимость
                return echo.NewHTTPError(http.StatusServiceUnavailable, "storage down")
        }
        return c.NoContent(http.StatusOK)
})

// в main, после прогрева: ready.Store(true)
```

atomic.Bool из двадцать первого урока — тот же конкурентный примитив, что мьютекс Memory: пробы будут дёргать каждую секунду, гонка здесь невозможна по построению. healthz не проверяет ничего внешнего — это принципиально: проверка зависимости в liveness превращает перегрузку базы в цикл перезапусков. Готовность — это ворота, а не отчёт: три статуса 503 подряд — и под выпадает из балансировки до первого 200.

### Пробы Kubernetes: конфигурация, которая не врёт

Манифест пробы — это честный контракт на поведение платформы, и заполнять его стоит, понимая каждое поле. httpGet задаёт путь и порт — /healthz и 1323; periodSeconds — частота опроса; timeoutSeconds — бюджет одного опроса (он обязан быть меньше периода, иначе пробы наложатся); failureThreshold — сколько подряд неудач считаются отказом; initialDelaySeconds — пауза перед первой пробой. Математика обнаружения проста и стоит запоминания: при periodSeconds 10 и failureThreshold 3 платформа заметит отказ максимум за полминуты — и столько же займёт вывод из балансировки при readiness. Ровно поэтому пробы не ставят за reverse proxy с тяжёлыми заголовками и не считают их в бизнес-метриках: это фоновый шум с периодом в секунды.

Для канона добавьте одно правило, связывающее уроки: пробы и /metrics выводятся из-под gzip. Двадцать второй урок оставил Skipper для SSE — расширьте его же условием: c.Path() == "/metrics" || c.Path() == "/healthz" || c.Path() == "/readyz". Сжатие двухсотбайтовых ответов — чистая потеря процессора, а на пробах — ещё и лишние миллисекунды в бюджете timeoutSeconds. И не забывайте, что readiness управляет выкатом: при rolling update новый под получает трафик только когда его /readyz впервые ответит 200 — это и есть автоматический откат сломанной версии без ручных действий.

### Проверьте себя: что сделает платформа

**Вопрос:** Проба /readyz трижды подряд ответила 503, liveness /healthz отвечает 200. Что сделает Kubernetes?

- Перезапустит контейнер: 503 — сигнал смерти
- Ничего: readiness без liveness не выполняется
- ✅ Выведет под из эндпоинтов Service и не отправит ему новый трафик — контейнер продолжит работать
- Расширит timeoutSeconds и будет ждать восстановления

Readiness управляет только потоком трафика, liveness — жизнью контейнера. 503 от /readyz при живом /healthz — честный сигнал «я работаю, но трафик не приму»: платформа убирает под из балансировки и возвращает его обратно на первый 200. Перезапуск случился бы только при отказах liveness — поэтому проверки зависимостей в liveness запрещены: они вызывают рестарт-цикл, который лечит проблему хуже.

### Логирование в проде: slog-JSON и request_id из middleware

Двадцать второй урок оставил мост: клиентская диагностика — ErrorHandler, серверная — лог, о чём и скажет двадцать третий. Собираем. В проде журнал канона обязан быть структурным: одна строка — один событийный JSON, который collector парсит без регулярок. Словарь slog из [девятого урока](/courses/echo/lessons/09-logirovanie/) уже умеет это: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) — и прод-ветка конструктора app.New из двадцать второго урока остаётся прежней, меняется только хендлер логгера. Пишем в stdout, а не в файл: контейнерная среда собирает стандартные потоки сама, ротация файлов — её забота, не вашей программы.

Сквозную корреляцию даёт middleware.RequestID из седьмого урока: заголовок X-Request-ID, сгенерированный или принятый от клиента, логируйте в каждой записи — тогда «ошибка 500 в 14:02» и «пользователь нажал кнопку» склеиваются одним поиском. Логгер-middleware седьмого урока печатает метод, URI, статус, латентность и remote_ip на каждый запрос — этого достаточно, а вот логировать тела запросов и заголовки Authorization нельзя: access-токен из [пятнадцатого урока](/courses/echo/lessons/15-jwt/) в логе — это утечка секрета в раздел с логами, куда доступ шире, чем в прод-процесс. Наконец, уровень по среде: DEBUG — локалка, INFO — прод; ERROR — только для событий, которые требуют действия, иначе тревоги по логам размоются.

### Канон: slog в проде с request_id

```go
// cmd/server/main.go — композиция логгера из девятого урока
if prod {
        slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout,
                &slog.HandlerOptions{Level: slog.LevelInfo})))
}

// внутренний app.go: в ErrorHandler для 500-х — не клиенту, а в лог
e.Logger.Error(err) // детали 500 — серверная диагностика

// логируя вручную, передавайте контекст запроса:
slog.InfoContext(c.Request().Context(), "task created",
        "task_id", t.ID, "request_id", c.Get(middleware.RequestIDKey))
```

JSONHandler пишет в stdout — collector контейнера подхватывает поток без ротации и конфигов. Уровень INFO: DEBUG-записи девятого урока в проде не форматируются вовсе — то же ветвление, что HideBanner из двадцать второго. request_id берётся из контекста, положенного RequestID-middleware: он же едет в ответе заголовком, и поддержка может склеить жалобу пользователя с записями сервера одним поиском.

### Тревоги по числам, которые вы теперь собираете

Метрики без тревог — это просто красиво нарисованная история. Первые три тревоги канона строятся на собранном сегодня без единой новой метрики. Первая — по счётчику: доля 5-хх в rate(echo_requests_total[5m]) выше одного процента значит «пользователи видят ошибки прямо сейчас». Вторая — по гистограмме: histogram_quantile(0.99, sum by (le, url) (rate(echo_request_duration_seconds_bucket[5m]))) выше вашего SLO — значит p99 деградировал; именно поэтому корзины DefBuckets хватает — перцентиль считается между ними грубо, но честно. Третья — по самой платформе: up{job="tasksapp"} == 0 — процесс не отвечает на скрейпы, и никакой код не нужен: это те самые liveness-пробы, только глазами Prometheus.

Что осознанно вынесено за скобки урока: выбор стеков алертинга, dashboards и SLO-практика — это инструменты экосистемы Prometheus, а не echo; канал здесь лишь отдаёт честные числа. Отмечу только правило приоритета: сначала тревога на факт (5-хх, недоступность), потом на деградацию (p99), и лишь потом на косвенность (объём трафика, размер очередей) — иначе дежурный научится игнорировать ваши графики раньше, чем они спасут прод.

> **danger: /metrics не должен гулять в интернет.** Метрики обязаны быть без JWT — иначе скрейпер не соберёт их, — а значит защита остаётся сетевой. /metrics отдаёт полную карту приложения: шаблоны маршрутов, хосты, коды ошибок, размеры ответов — это разведка, подаренная сканеру. Варианты: слушать метрики на отдельном админ-адресе — приём из двадцать второго урока, где pprof жив на 127.0.0.1:6060, — или резать путь на входе proxy. Правило одно: скрейпер Prometheus ходит по внутренней сети, curl снаружи получает 404 или таймаут.

### Соберите подключение метрик канона

Восстановите каноничную связку из README пакета: middleware собирает, хендлер отдаёт.

```go
import (
        "github.com/labstack/echo-contrib/echoprometheus"
)

func New(store service.TaskStore, secret []byte, prod bool) *echo.Echo {
        e := echo.New()
        e.Use(echoprometheus.NewMiddleware("___"))
        e.GET("/___", echoprometheus.___())
        return e
}
```

Подсказка: Имя приложения-подсистемы, стандартный путь сбора и второй конструктор пакета.

Правильные ответы в пропусках: `tasksapp`, `metrics`, `NewHandler`

### Порядок: подключаем мониторинг echo по шагам

Расставьте шаги подключения наблюдаемости от зависимости до первого графика.

Правильный порядок строк:

1. `go get github.com/labstack/echo-contrib@latest`
2. `echoprometheus.NewMiddleware("tasksapp") — фабрика MiddlewareFunc`
3. `e.Use(...) — поставить middleware после Logger и Recover`
4. `e.GET("/metrics", echoprometheus.NewHandler()) — маршрут вне /api/v1`
5. `Skipper выводит /metrics и пробы из-под gzip`
6. `регистрируем /healthz и /readyz на верхнем уровне без JWT`
7. `curl localhost:1323/metrics — сверить HELP/TYPE и рост echo_requests_total`
8. `prometheus.yml: job_name tasksapp, targets [localhost:1323], scrape_interval 15s`
9. `проверка PromQL: rate(echo_request_duration_seconds_count[5m]) отвечает`

### Карточки: язык наблюдаемости

- **Liveness-проба** — «Жив ли процесс». Падает → платформа перезапускает контейнер. Дешёвая, без внешних зависимостей: /healthz = c.NoContent(200).
- **Readiness-проба** — «Готов ли принимать трафик». Падает → под выводится из балансировки без рестарта. Проверяет зависимости и прогрев: /readyz = 503 при storage down.
- **Counter** — Метрика, которая только растёт; при рестарте сбрасывается, поэтому смотрят rate(). В каноне: echo_requests_total и tasksapp_tasks_created_total.
- **Histogram** — Наблюдения по корзинам: серии _bucket, _sum, _count. Перцентили — histogram_quantile. У echoprometheus: request_duration_seconds с DefBuckets до 10с.
- **Кардинальность** — Число временных серий = комбинации ярлыков. Безопасны дискретные значения (code, method); идентификаторы и сырые пути 404-х взрывают память — DoNotUseRequestPathFor404.
- **scrape_interval** — Период опроса /metrics Prometheus-ом (обычно 15с). С ним согласованы failureThreshold/periodSeconds проб и окна rate() в PromQL.

### Канон наблюдаем

Канон на :1323 теперь отвечает за себя. Метрики: e.Use(echoprometheus.NewMiddleware("tasksapp")) и /metrics от echoprometheus.NewHandler() — счётчик requests_total и три гистограммы с ярлыками code, method, host, url; бизнес-счётчик tasksapp_tasks_created_total рядом в /metrics через тот же реестр по умолчанию. Кардинальность под контролем: url — шаблон маршрута, 404-е схлопнуты флагом, авторские ярлыки — дискретные. Пробы: /healthz — дешёвый 200, /readyz — 503, пока готовность не подтверждена; конфиг Kubernetes согласует periodSeconds, timeoutSeconds и failureThreshold, а readiness управляет выкатом. Логи: slog-JSON в stdout с request_id из седьмого урока, секреты из пятнадцатого — мимо журнала. Тревоги: доля 5-хх, p99 по гистограмме, up.

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; подключите echoprometheus в app.New, заведите обе пробы, соберите prometheus.yml и убедитесь, что rate() по вашему канону отвечает, а /readyz честно мигает 503 на выключенной зависимости.

Следующий, двадцать четвёртый урок курса echo — Развертывание: бинарник, Docker, systemd и reverse proxy. Живой, наблюдающий за собой процесс осталось упаковать: собрать статичный бинарник, уложить в контейнер с HEALTHCHECK по /healthz, научить останавливаться по SIGTERM без обрыва in-flight запросов и выставить наружу через прокси. Навигация по курсу — на [странице курса Echo](/courses/echo/); слои и конструктор app.New, куда встали метрики и пробы, — в [уроке про структуру проекта](/courses/echo/lessons/21-struktura/); Skipper gzip, расширенный пробами, — в [уроке про производительность](/courses/echo/lessons/22-performance/); Logger и RequestID — в [уроке про встроенные middleware](/courses/echo/lessons/07-vstroennye-middleware/); словарь slog — в [уроке про логирование](/courses/echo/lessons/09-logirovanie/); JWT, чей секрет не живёт в логах, — в [уроке про JWT-аутентификацию](/courses/echo/lessons/15-jwt/).

## Коротко: что запомнить

- Мониторинг echo — три канала: метрики (echoprometheus: NewMiddleware + NewHandler на /metrics), health-пробы (/healthz и /readyz без JWT), логи (slog-JSON в stdout с request_id). Приложение ничего не пушит — Prometheus сам скрейпит раз в scrape_interval.
- echoprometheus регистрирует requests_total (counter) и три гистограммы request_duration_seconds / request_size_bytes / response_size_bytes с ярлыками code, method, host, url. Корзины длительности — prometheus.DefBuckets (до 10с), размеры — от 1 КБ до 10 МБ.
- Кардинальность — главная ловушка: url берёт c.Path() (шаблон маршрута), но 404-е без маршрута уезжают сырым путём — DoNotUseRequestPathFor404 и LabelFuncs держат число серий; идентификаторы пользователей в ярлыках запрещены экономикой.
- Бизнес-метрики — promauto.NewCounter в дефолтный реестр: tasksapp_tasks_created_total.Inc() после подтверждения сервисом; NewHandler по умолчанию читает DefaultGatherer, поэтому бизнес-метрика появляется на /metrics без настроек.
- Liveness ≠ readiness: первая — «жив ли» (падение = рестарт, потому без внешних зависимостей), вторая — «готов ли» (падение = вывод из балансировки без рестарта). Пробы дёшевы, вне /api/v1 и JWT, согласованы с periodSeconds/timeoutSeconds/failureThreshold; readiness гейтит выкат.
- Логи в проде: slog JSONHandler в stdout, Level INFO, request_id из RequestID-middleware в каждой записи; секреты (Authorization/JWT) и тела запросов в лог не попадают; ERROR — только на события, требующие действия.
- Тревоги первого дня: доля 5-хх в rate(echo_requests_total[5m]), p99 через histogram_quantile по корзинам, up{job="tasksapp"} == 0; /metrics без JWT — значит, закрыт сетью: админ-адрес или deny на proxy.

## Домашнее задание

Доведите канон до наблюдаемого и докажите пробой. Первое: подключите echoprometheus.NewMiddleware("tasksapp") в app.New после Logger и Recover и зарегистрируйте e.GET("/metrics", echoprometheus.NewHandler()) вне /api/v1; curl-ом убедитесь, что HELP/TYPE echo_requests_total отдаются, а после пары запросов по /api/v1/tasks растут образцы. Второе: добавьте Skipper, который пропускает /metrics, /healthz и /readyz из-под gzip из двадцать второго урока, и измерьте до/после — почему сжатие 200-байтовых проб оказалось бессмысленным. Третье: заведите /healthz через c.NoContent(http.StatusOK) и /readyz с atomic.Bool и store.Ping; до ready.Store(true) проба обязана отвечать 503, после — 200; объясните, почему проверка хранилища не должна жить в /healthz. Четвёртое: создайте promauto-счётчик tasksapp_tasks_created_total и инкрементируйте его в makeCreateTask после svc.Create; убедитесь, что отказ по ErrLimitReached не растит счётчик, и добавьте CounterVec с ярлыком outcome для разложения по исходам. Пятое: переведите прод-логгер в slog.NewJSONHandler(os.Stdout) с уровнем INFO и проследите, что request_id из RequestID-middleware попадает в записи; найдите в логах место, где вы чуть не напечатали заголовок Authorization, и объясните, чем это кончилось бы. Шестое: соберите prometheus.yml со scrape_interval 15s, поднимите Prometheus локально, постройте rate(echo_requests_total[5m]) и p99 через histogram_quantile; уроните /readyz-зависимость и зафиксируйте, что up остаётся 1 — процесс жив, хотя трафик он не примет. Седьмое: включите DoNotUseRequestPathFor404, попеняли несуществующие пути сканером и сравните число серий echo_requests_total с ярлыком url до и после флага.

## Вопросы и ответы по уроку

### Почему /metrics не закрыт JWT, если это обычный маршрут Echo?

Потому что скрейпер Prometheus — не пользователь: он не носит access-токен и не умеет их обновлять, а каждую аутентификацию на скрейпе пришлось бы синхронно решать в Prometheus. Это не делает /metrics публичным: он публичен только внутри сети мониторинга. Вариант канона — слушать его на основном порту и резать на edge-proxy, либо повторить приём двадцать второго урока с pprof: отдельный ListenAndServe на 127.0.0.1:6060, который недостижим извне по построению. Пробы /healthz и /readyz — те же соображения: платформа опрашивает их без токена.

### Чем измерять длительность запроса, если response уходит потоком (SSE из девятнадцатого урока)?

Для обычных запросов гистограмма echoprometheus фиксирует время до возврата хендлера — этого достаточно. Долгоиграющие стримы ломают картину: событие длится минуты, попадает в последнюю корзину (10с+) и портит p99. Практика: Skipper-ом исключить стриминговые пути из замера — так же, как gzip из двадцать второго урока их не сжимает, — и для них вести собственные метрики (число активных стримов гейджем, число сообщений счётчиком). В-flight-соединения в метрике времени не живут — и это честно: латентность «ответа» у стрима не имеет смысла.

### Нужен ли отдельный пакет логов/трейсов вроде OpenTelemetry вместо slog?

Пакет нужен, когда связка «запрос → события» перестаёт помещаться в логи с request_id: несколько сервисов, очередь, нужна трассировка переходов. Для одного echo-процесса slog-JSON с request_id из RequestID-middleware покрывает 90% расследований и ничего не стоит. Правило ввода OTel — не «прод», а «межсервисная граница»: как только запрос начинает проходить через два процесса, логи по request_id рассыпаются, и появляются трейсы. Метрики же в любом сценарии остаются Prometheus-овскими — echo-contrib регистрирует их сегодня.
