---
title: "Мониторинг и метрики"
description: "Метрики Connect через интерсепторы: Prometheus, счётчики вызовов и гистограммы латентности по процедурам, /metrics на общем mux, метод RED и цена карточности."
course: "Connect: современный RPC от Buf"
url: "https://golangmaster.ru/courses/connect/lessons/17-metriki/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Мониторинг и метрики

*Курс «Connect: современный RPC от Buf», урок 17 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/connect/lessons/17-metriki/*

мониторинг connect — это интерсептор, который измеряет каждый вызов: счётчики с процедурой и кодом в метках, гистограммы latency, датчик текущих вызовов и /metrics на том же mux, где живёт сам сервис. Начнём с официального otelconnect, соберём собственный слой Prometheus для полного контроля и разберём карточность — главную цену метрик.

У вашего notes-сервиса есть TLS, авторизация, лимиты и сжатие, но всё это работает вслепую: ни вы, ни дежурный инженер не знаете, сколько вызовов приходит, сколько из них падает и что происходит с длительностью. мониторинг connect — это слой наблюдаемости, который отвечает на три вопроса в реальном времени: какой поток запросов, какая доля ошибок, какая задержка. В седьмом уроке мы заложили фундамент: интерсептор знает процедуру через req.Spec().Procedure, видит и успех, и провал, и обёрнут во всю цепочку WithInterceptors — идеальное место для измерений. В тринадцатом уроке мы обещали, что auth-события лягут в счётчики, — в этом уроке обещание исполняется: каждое отклонение по токену станет значением метки code. В четырнадцатом мы оставили второй мостик: сжатие нельзя настраивать вслепую, эффект надо увидеть на живом трафике — здесь появятся и размеры сообщений. План такой: сначала официальная машина — пакет connectrpc.com/otelconnect, который канон из седьмого урока советовал начать с него; затем собственный интерсептор с Prometheus для полного контроля над именами, метками и бакетами; затем стриминги, размеры, карточность и сборка /metrics на общем mux. Ключевые слова connect metrics и RED-метод приведут вас в доки Prometheus — сверяйтесь, когда пойдёте дальше урока.

### Что видно из интерсептора: RAW-метод на RPC

Мониторинг connect начинается с честного ответа на вопрос «что вообще измерять». Классический ответ индустрии — метод RED: Rate, Errors, Duration — частота вызовов, доля ошибок, длительность. Для RPC это ровно то, что видно из интерсептора, и ничего доустанавливать не нужно. Rate: интерсептор выполняется на каждом вызове, значит инкремент счётчика в нём — это точная частота по каждой процедуре. Errors: после вызова next у интерсептора в руках error — и Connect даёт типизированный код через connect.Error, шестнадцать кодов из шестого урока; код в метке — и у вас на доске видно, что растёт именно unauthenticated, а не internal. Duration: time.Now до и после next — длительность всего вызова целиком, вместе с хендлером и всеми внутренними слоями. Обратите внимание на масштаб: интерсептор один, а метрик полный набор на все методы всех сервисов — то же свойство, что делало его идеальным местом для логов в седьмом уроке. Чего из интерсептора не видно: содержимое сообщений (и хорошо — бизнес-данные в метриках не место), состояние очередей и GC (это метрики процесса, их собирает клиент Prometheus автоматически), поведение конкретной реплики за балансировщиком (это решают метки инстанса и сервис-дискавери). Граница простая: всё, что про вызов RPC, — в интерсептор; всё, что про процесс и инфраструктуру, — рядом, в том же реестре, но из других источников.

### Официальный слой: otelconnect одной опцией

```go
import "connectrpc.com/otelconnect"

path, handler := notesv1connect.NewNoteServiceHandler(
        &notesServer{},
        connect.WithInterceptors(
                otelconnect.NewInterceptor(
                        otelconnect.WithTracerProvider(tp),
                        otelconnect.WithMeterProvider(mp),
                ),
        ),
)
mux.Handle(path, handler)
```

Канон из седьмого урока в действии: официальный интерсептор OpenTelemetry расставляет на каждый вызов трейсы и метрики с процедурой в атрибутах — без единой собственной строки. TracerProvider и MeterProvider приходят из вашей OTel-сборки; без них интерсептор работает с глобальными провайдерами. Это старт по умолчанию, а дальше — собственный слой, если нужен полный контроль.

### Свой слой Prometheus: счётчики, гистограммы, датчики

Зачем самописный слой, если есть otelconnect? Две рабочие причины. Первая — экспорт: Prometheus скрейпит HTTP-эндпоинт /metrics и ждёт формат Prometheus; если ваша инфраструктура уже стоит на этом стеке, метрики connect в его реестре — самое короткое расстояние между интерсептором и дашбордом. Вторая — контроль: свои имена, свои метки, свои бакеты гистограмм; библиотечный слой хорош дефолтами, но когда дежурный хочет видеть notes_rpc_duration_seconds с конкретными бакетами, а не абстрактные histogram_seconds, побеждает собственный код. Три типа метрик из client_golang закрывают RAW-метод целиком. Counter — монотонный счётчик: количество вызовов, только растёт; парный с ним rate вычислит Prometheus сам. Histogram — распределение длительности по бакетам: из него Prometheus считает перцентили p95 и p99, в отличие от среднего, которое хвост прячет. Gauge — текущее значение туда-сюда: сколько вызовов исполняется прямо сейчас, полезно для насыщения. Каждая метрика с метками (label): для счётчика вызовов — procedure и code, для гистограммы — procedure. Правило именования в стиле Prometheus: префикс приложения, объект, единица измерения — notes_rpc_requests_total, notes_rpc_duration_seconds; единица длительности — секунды, всегда секунды, Prometheus любит базовые единицы. Регистрация через prometheus.MustRegister в init — и все три метрики живут в дефолтном реестре, откуда их заберёт promhttp.

### Декларация метрик notes-сервиса

```go
import (
        "github.com/prometheus/client_golang/prometheus"
        "github.com/prometheus/client_golang/prometheus/promhttp"
)

var rpcRequests = prometheus.NewCounterVec(
        prometheus.CounterOpts{
                Name: "notes_rpc_requests_total",
                Help: "RPC calls by procedure and outcome code",
        },
        []string{"procedure", "code"},
)

var rpcDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
                Name:    "notes_rpc_duration_seconds",
                Help:    "Unary call duration",
                Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5},
        },
        []string{"procedure"},
)

var rpcInFlight = prometheus.NewGauge(
        prometheus.GaugeOpts{
                Name: "notes_rpc_in_flight",
                Help: "Calls being served right now",
        },
)

func init() {
        prometheus.MustRegister(rpcRequests, rpcDuration, rpcInFlight)
}
```

Три метрики RAW-метода: счётчик с двумя метками (procedure, code), гистограмма с одной (procedure) и датчик без меток. Бакеты от пяти миллисекунд до двух с половиной секунд — стартовая сетка для сервисов с ответами в десятки миллисекунд; её стоит пересобрать под свой профиль, когда накопится статистика. MustRegister кладёт всё в дефолтный реестр.

**Вопрос:** Почему у счётчика вызовов метки procedure и code, но не user_id?

- Так требует протокол: Prometheus разрешает только строки без цифр
- ✅ Карточность: количество серий = произведение уникальных значений меток, и на каждую пару (пользователь, процедура) заводится отдельная серия — их будут миллионы
- user_id недоступен в интерсепторе, метки взять неоткуда
- Персональные данные ломают формат экспозиции /metrics

Карточность — главная цена метрик: каждая уникальная комбинация значений меток — отдельная временная серия, которую Prometheus хранит, скрейпит и считает. Тысячи пользователей на десятки процедур — сотни тысяч серий с одного инстанса, и реестр захлебывается. Метки procedure (десятки значений) и code (шестнадцать кодов) ограничены самим API; user_id — неограничен. Кто вызвал — это вопрос логов и трейсов, а не метрик.

### Интерсептор метрик: unary от и до

Теперь сердце урока — интерсептор, который превращает декларации из прошлого блока в измерения. Скелет тот же, что у логирующего интерсептора из седьмого урока: до next — подготовка, после — фиксация. До: rpcInFlight.Inc() и defer rpcInFlight.Dec() — датчик текущих вызовов корректно работает даже при панике благодаря defer; time.Now() — стартовая точка. После: ответ или ошибка. Если err равен nil — код CodeOK; иначе приведение к connect.Error через errors.As — паттерн, который вы знаете с шестого урока, — и connectErr.Code() даёт типизированный код. Здесь же исполняется обещание тринадцатого урока: запрос с битым токеном, отклонённый auth-интерсептором, вернётся сюда как ошибка с кодом CodeUnauthenticated — тем самым unauthenticated из шестого урока, HTTP 401 — и счётчик запишет пару (процедура, code_unauthenticated). Метки собираются WithLabelValues: первая — req.Spec().Procedure, вторая — code.String(). Затем два наблюдения: rpcRequests...Inc() и rpcDuration...Observe(time.Since(start).Seconds()). Две детали, которые легко упустить. Первая: Observe и Inc идут после next в любом случае — через явную последовательность или через единый defer; если поставить фиксацию до next, вы будете измерять только момент запуска. Вторая: duration считается от входа в интерсептор до выхода из next — то есть включает все внутренние слои цепочки, auth и хендлер; это желаемое поведение, но помните про него, когда будете сравнивать цифры с логами из седьмого урока.

### WrapUnary с кодом из errors.As

```go
type metricsInterceptor struct{}

func (m *metricsInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
        return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
                rpcInFlight.Inc()
                defer rpcInFlight.Dec()

                start := time.Now()
                res, err := next(ctx, req)

                code := connect.CodeOK
                if err != nil {
                        var connectErr *connect.Error
                        if errors.As(err, &connectErr) {
                                code = connectErr.Code()
                        } else {
                                code = connect.CodeUnknown
                        }
                }

                rpcRequests.WithLabelValues(req.Spec().Procedure, code.String()).Inc()
                rpcDuration.WithLabelValues(req.Spec().Procedure).
                        Observe(time.Since(start).Seconds())
                return res, err
        }
}
```

Полный Raw-цикл: датчик, таймер, вызов, извлечение кода через errors.As, два наблюдения. Отклонение от auth-интерсептора тринадцатого урока приедет сюда ошибкой — счётчик получит метку code=unauthenticated, и на дашборде будет видно и число попыток обхода, и всплески отказов. Ошибки без connect.Error (паника, транспорт) — CodeUnknown.

### Какая метка ляжет в счётчик?

```go
$ curl -X POST http://localhost:8080/notes.v1.NoteService/GetNote \
    -H "Content-Type: application/json" -d '{"id": "1"}'

// Хендлер собран так:
connect.WithInterceptors(&metricsInterceptor{}, &authInterceptor{})
```

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

- Счётчик не изменится: auth отклонил запрос до хендлера, метрики внутри не сработали
- ✅ notes_rpc_requests_total{procedure="/notes.v1.NoteService/GetNote", code="unauthenticated"} +1
- notes_rpc_requests_total{procedure="/notes.v1.NoteService/GetNote", code="internal"} +1
- notes_rpc_in_flight останется больше нуля: defer не выполнится при отказе

Луковица из седьмого урока: первый в WithInterceptors — внешний слой. metrics стоит раньше auth, значит он оборачивает и его отказ тоже: next вернёт ошибку unauthenticated, errors.As вытащит код, счётчик запишет пару с code=unauthenticated. Именно поэтому порядок интерсепторов — контракт: поменяйте местами — и метрики увидят только прошедшие auth, а отклонённые станут невидимыми. defer сработает всегда, датчик честно вернётся к нулю.

### Стримы: тот же код + подсчёт сообщений

Стриминговые вызовы из восьмого урока измеряются тем же интерсептором с теми же правилами — с одним расширением. WrapStreamingHandler получает conn вместо req: стартовая точка — conn.Spec().Procedure, заголовков — conn.RequestHeader(), итоговый код — из той же ошибки, которую метод возвращает через return. Датчик и гистограмма ложатся без изменений: стрим длится дольше unary, и это увидите в бакетах. А вот для частоты сообщений нужна обёртка: интерфейс connect.StreamingHandlerConn даёт методы Receive и Send, и если обернуть соединение структурой, встраивающей исходный conn, можно инкрементировать счётчики на каждом успешном сообщении — receive для входящих, send для исходящих. Это принцип встраивания Go: поля и методы исходного conn достаются бесплатно, переопределяются только два. Счётчик сообщений — отдельная метрика с метками procedure и direction, потому что стрим асимметричен: серверный поток может слать тысячи сообщений, не получая ни одного. Помните урок из восьмого про отмену контекста: Receive и Send возвращают ошибки — счётчик инкрементируется только на err == nil, иначе вы посчитаете сообщения, которых не было. Три метода интерфейса Interceptor обязаны существовать все — канон седьмого урока: WrapStreamingClient у метрик остаётся pass-through, если вы не меряете исходящие вызовы.

### Обёртка conn для счётчика сообщений

```go
var rpcStreamMessages = prometheus.NewCounterVec(
        prometheus.CounterOpts{
                Name: "notes_rpc_stream_messages_total",
                Help: "Messages in streams by procedure and direction",
        },
        []string{"procedure", "direction"},
)

type countedConn struct {
        connect.StreamingHandlerConn
        procedure string
}

func (c *countedConn) Receive(msg any) error {
        err := c.StreamingHandlerConn.Receive(msg)
        if err == nil {
                rpcStreamMessages.WithLabelValues(c.procedure, "receive").Inc()
        }
        return err
}

func (c *countedConn) Send(msg any) error {
        err := c.StreamingHandlerConn.Send(msg)
        if err == nil {
                rpcStreamMessages.WithLabelValues(c.procedure, "send").Inc()
        }
        return err
}

func (m *metricsInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
        return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
                rpcInFlight.Inc()
                defer rpcInFlight.Dec()
                conn = &countedConn{StreamingHandlerConn: conn, procedure: conn.Spec().Procedure}
                start := time.Now()
                err := next(ctx, conn)
                // код ошибки — тот же errors.As, что в WrapUnary
                rpcDuration.WithLabelValues(conn.Spec().Procedure).Observe(time.Since(start).Seconds())
                return err
        }
}
```

Встраивание StreamingHandlerConn: переопределены Receive и Send, всё остальное (Spec, RequestHeader, ResponseHeader) пробрасывается исходному conn. Счётчик сообщений с меткой direction разделяет входящий и исходящий потоки. Длительность стрима считается той же гистограммой, что и unary, — на дашборде стримы и unary лягут рядом, и разница жанров станет видна.

### Сколько сообщений посчитает счётчик?

```go
// Bidi-стрим: клиент шлёт 3 сообщения, хендлер эхо-отвечает на каждое.
// На третьем Receive транспорт вернул ошибку (клиент отключился):
// received: err != nil на третьем вызове
// send: 2 успешных ответа ушли до разрыва
err := next(ctx, conn) // вернул connect.Error с CodeCanceled
```

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

- receive = 3, send = 3: счётчик считает вызовы Receive/Send, а не успехи
- ✅ receive = 2, send = 2: третий Receive вернул ошибку и не посчитан, ответ на него не ушёл
- receive = 0, send = 0: стрим упал с ошибкой, метрики обнуляются
- receive = 2, send = 3: счётчик send инкрементируется до транспорта

Обёртка инкрементирует только успешные операции: err == nil. Два сообщения получены и на оба отправлены ответы — оба раза счётчики выросли; третий Receive вернул ошибку отмены, инкремента нет, третьего ответа не существует. Итоговая ошибка стрима (CodeCanceled) уйдёт в гистограмму и счётчик вызовов, но не изменит счётчик сообщений. Именно так метрики отличают «стрим кончился» от «стрим умер».

**Вопрос:** Зачем гистограмма с бакетами, если можно усреднять длительность?

- Среднее считать дороже по CPU, гистограмма дешевле
- ✅ Среднее прячет хвост: сервис со средним 30 мс может отдавать p99 в секунды, и именно хвост ломает пользовательский опыт
- Гистограмма нужна только для стримов, для unary достаточно среднего
- Prometheus не умеет хранить средние значения

RAW-метод требует Duration с хвостами: распределение длительности почти всегда с тяжёлым хвостом из длинных вызовов, и среднее его полностью маскирует. Гистограмма по бакетам даёт Prometheus считать p95 и p99 запросом histogram_quantile — вы увидите и медиану, и хвост. На дашборде держите p99 рядом с p50: расползание между ними — первый сигнал деградации, задолго до роста среднего.

### Размеры сообщений: исполнение обещания четырнадцатого урока

Четырнадцатый урок оставил прямой мостик: чтобы решить, окупается ли сжатие, нужны размеры сообщений на живом трафике — теперь они у вас есть, и вот как их снимать. Для unary-вызовов в Connect-протоколе размер входящего сообщения доступен как Content-Length в заголовках: req.Header().Get("Content-Length") — парсите строку в число и подаёте в гистограмму notes_rpc_request_bytes с бакетами в байтах и КиБ. Честные оговорки: заголовок даёт размер тела запроса — сжатие из четырнадцатого урока меняет эту цифру (замер показывает сжатый размер, реальный объём данных больше, и именно это вам нужно для решения о пороге); gRPC-жанры кадрируют сообщения собственной рамкой и Content-Length означает размер всего кадра, а не сообщения; у ответа соответствующий заголовок появляется после сериализации. Практический рецепт: снимайте распределение размеров на входе, сравнивайте с порогом WithCompressMinBytes из четырнадцатого урока — если медиана ниже порога, а хвост редкий, сжатие почти не работает и его место под вопросом; если масса трафика выше порога, gzip окупается и латентность от распаковки не заметна. Вторая связь — с лимитами тринадцатого: гистограмма размеров покажет, насколько далеко реальные payload-ы от WithReadMaxBytes; если p99 вплотную к лимиту, любой рост трафика начнёт превращаться в ресурсные отказы, и лимит пора пересматривать. Так связываются три урока: размеры из этого урока, порог из четырнадцатого, потолок из тринадцатого.

### Карточность: главная цена и дисциплина меток

Дисциплина меток — то, что отличает метрики на годы от метрик на неделю. Правило простое: метка годится, только если множество её значений ограничено дизайном API. procedure ограничено числом методов ваших proto-сервисов — десятки; code ограничено шестнадцатью кодами; direction — двумя. А вот список кандидатов, которые кажутся естественными и разрушают реестр: user_id и идентификаторы сущностей (тысячи-миллионы), URL с параметрами (каждый id — новая серия), IP-адреса (DHCP и мобильные сети делают их бесконечными), версии клиентов (растут с каждым релизом неограниченно). Цена ошибки кумулятивна: серии создаются и живут вечно, память растёт на каждом инстансе, скрейп замедляется, а запросы к реестру деградируют. Если диагностическая потребность в детализации до пользователя реальна — это вопрос логов с полями и трейсов, где каждая запись самодостаточна и дешева, а не временной серии на каждого клиента. Второй аспект дисциплины — консистентность: одна и та же метка должна называться одинаково во всех метриках (procedure, не proc и не method), иначе дашборды не склеятся. Третий — регистрация: все метрики сервиса регистрируются один раз в init или в конструкторе; динамическая регистрация во время работы — путь к гонкам и утечкам. Эти три правила стоят дешевле всего в начале и дороже всего в конце.

### Сборка сервера: /metrics на общем mux

```go
mux := http.NewServeMux()
mux.Handle("GET /metrics", promhttp.Handler())
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
})

path, handler := notesv1connect.NewNoteServiceHandler(
        &notesServer{},
        connect.WithInterceptors(&metricsInterceptor{}, &authInterceptor{}),
        connect.WithReadMaxBytes(1<<20),
)
mux.Handle(path, handler)

log.Fatal(http.ListenAndServe(":8080", mux))
```

/metrics живёт на том же mux, что и RPC-хендлер, — отдельного порта не нужно, а паттерн Go 1.22 ограничивает метод. Дефолтный реестр promhttp отдаёт не только ваши метрики, но и go_* (горутины, память, GC) и process_* (CPU, файловые дескрипторы) — здоровье процесса бесплатно. Эндпоинт отдаётся по HTTP так же, как RPC, — TLS из тринадцатого урока защищает и его.

**Вопрос:** Что окажется на /metrics, кроме ваших notes_rpc_* метрик?

- Ничего: promhttp отдаёт только зарегистрированное вручную
- Содержимое базы данных: реестр Prometheus читает таблицы через драйвер
- ✅ Метрики процесса и рантайма Go: go_goroutines, go_gc_duration_seconds, process_cpu_seconds — их кладёт дефолтный реестр client_golang
- Логи сервиса за последний час

Дефолтный реестр client_golang собирает метрики рантайма и процесса автоматически: горутины, память, GC, CPU, дескрипторы, RSS. Это половина оперативной диагностики бесплатно — всплеск горутин или памяти виден до того, как деградируют вызовы. RPC-метрики отвечают на вопрос «что происходит с API», рантаймные — «что происходит с процессом», и обе группы приезжают одним скрейпом.

### Каталог метрик RPC-сервиса

| Метрика | Тип | Метки | На какой вопрос отвечает |
| --- | --- | --- | --- |
| notes_rpc_requests_total | Counter | procedure, code | сколько вызовов и с какими исходами, включая отказы auth |
| notes_rpc_duration_seconds | Histogram | procedure | латентность и хвосты: p50, p95, p99 по методам |
| notes_rpc_in_flight | Gauge | нет | сколько вызовов исполняется прямо сейчас — насыщение |
| notes_rpc_stream_messages_total | Counter | procedure, direction | объём сообщений в стримах, входящих и исходящих |
| notes_rpc_request_bytes | Histogram | procedure | распределение размеров входа против порога сжатия и лимита |
| go_* и process_* | разные | нет | здоровье процесса: горутины, память, GC, CPU |

### Соберите фиксацию кода и меток

Восстановите извлечение кода ошибки и лейблы счётчика в WrapUnary.

```go
start := time.Now()
res, err := next(ctx, req)

code := connect.CodeOK
if err != nil {
        var connectErr *connect.Error
        if errors.___(err, &connectErr) {
                code = connectErr.___()
        }
}

rpcRequests.WithLabelValues(___, code.String()).Inc()
rpcDuration.WithLabelValues(req.Spec().Procedure).Observe(time.Since(start).Seconds())
```

Подсказка: Ошибку в connect.Error извлекает errors.As, код даёт метод Code(), метка процедуры — req.Spec().Procedure.

Правильные ответы в пропусках: `As`, `Code`, `req.Spec().Procedure`

### Путь одного вызова через слой метрик

Расставьте события одного unary-вызова GetNote в правильном порядке.

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

1. `mux маршрутизирует путь /notes.v1.NoteService/GetNote в Connect-хендлер`
2. `интерсептор метрик инкрементирует rpcInFlight и ставит time.Now`
3. `next вызывает следующий слой — auth-интерсептор из тринадцатого урока`
4. `хендлер исполняется и возвращает ответ или connect.Error`
5. `errors.As извлекает код — CodeOK или фактический код отказа`
6. `счётчик с метками procedure и code получает инкремент`
7. `длительность уходит в гистограмму через Observe`
8. `defer декрементирует rpcInFlight, ответ уходит клиенту`

> **Карточность и секреты.** Не кладите в метки user_id, идентификаторы сущностей, IP, URL с параметрами и любые неограниченные множества — серия на каждую комбинацию живёт вечно, и реестр растёт необратимо. Метки — место для ограниченных измерений, а не для деталей; детальность — в логах и трейсах. И никогда не пишите в метки токены и заголовки из тринадцатого урока: /metrics читается скрейпером, то есть копия секрета едет в систему мониторинга на постоянное хранение.

> **RED-доска и алерты.** Соберите дашборд из трёх панелей: rate по процедурам (rate от notes_rpc_requests_total), доля ошибок (отношение серий с кодами, отличными от ok, к общему), длительность (p50 и p99 из гистограммы). Первый алерт — на долю ошибок, второй — на p99 выше бюджета; оба считаются запросами Prometheus без дополнительных метрик. Бюджет латентности зафиксируйте числом до включения сжатия из четырнадцатого урока — иначе нечем будет оценить его эффект.

> **Как проходить этот курс.** Код урока — настоящий Go: интерсептор метрик, декларации и монтаж /metrics копируются в проект из третьего урока один в один. Браузерная песочница сайта сетевых вызовов и Prometheus не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а метрики проверяйте curl-ом на /metrics и локальным Prometheus.

### Карточки: метрики Connect

- **RAW-метод для RPC** — Rate, Errors, Duration: частота вызовов (счётчик), доля ошибок (метка code), длительность (гистограмма). Всё измеряется одним интерсептором, который видит процедуру и исход каждого вызова.
- **otelconnect** — connectrpc.com/otelconnect — официальный интерсептор OpenTelemetry: трейсы и метрики на каждый вызов одной опцией WithInterceptors(otelconnect.NewInterceptor(...)). Старт по умолчанию, канон седьмого урока.
- **Код ошибки в метке** — После next: errors.As(err, &connectErr) → connectErr.Code(); успех — CodeOK. Отказ auth-интерсептора приедет как code=unauthenticated — auth-события видны в счётчике.
- **Карточность** — Число серий = произведение уникальных значений меток. Годятся procedure и code (ограничены API), нельзя user_id, IP, URL с параметрами — серии вечны и множатся.
- **Подсчёт сообщений стрима** — Обёртка conn через встраивание StreamingHandlerConn: переопределить Receive и Send, инкремент только при err == nil, метка direction разделяет вход и выход.
- **/metrics на mux** — mux.Handle("/metrics", promhttp.Handler()) на общем mux с RPC-хендлером. Дефолтный реестр добавляет go_* и process_* — здоровье процесса тем же скрейпом.

### Что дальше по курсу

Слой наблюдаемости собран: интерсептор измеряет каждый вызов, счётчик хранит исходы, гистограмма — длительности, датчик — насыщение, /metrics отдаёт всё это тем же сервером, что и RPC. Схема сервиса отныне меняется под наблюдением — а менять её придётся: следующий урок — версионирование схем, эволюция proto без поломки клиентов: [восемнадцатый](/courses/connect/lessons/18-versii/). Затем — интеграция с фреймворками net/http, где хендлер встанет в чужие mux и цепочки middleware: [девятнадцатый](/courses/connect/lessons/19-net-http/). Для повторения: механика перехватчиков и луковица — в [седьмом уроке](/courses/connect/lessons/07-interceptors/), стриминговые соединения — в [восьмом](/courses/connect/lessons/08-streaming/), коды ошибок и errors.As — в [шестом](/courses/connect/lessons/06-oshibki/), порог сжатия, который вы теперь проверяете метриками, — в [четырнадцатом](/courses/connect/lessons/14-compression/). Программа всех двадцати пяти уроков — на [странице курса](/courses/connect/).

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

- Мониторинг connect — интерсептор: он видит процедуру, исход и длительность каждого вызова и один на все методы; RAW-метод — rate, errors, duration.
- Старт по умолчанию — официальный otelconnect одной опцией; собственный слой Prometheus даёт контроль над именами, метками и бакетами.
- Счётчик с метками procedure и code: код извлекается errors.As из connect.Error; отказ auth виден как code=unauthenticated — обещание тринадцатого урока.
- Гистограмма с бакетами вместо среднего: Prometheus считает p95 и p99, среднее прячет хвост; длительность — в секундах.
- Стримы — тот же интерсептор плюс обёртка conn через встраивание: счётчик успешных Receive и Send с меткой direction.
- Карточность — главная цена: метки только с ограниченными множествами (procedure, code); user_id, IP и URL — в логи и трейсы, не в метки.
- Размеры входящих сообщений из Content-Length связывают порог сжатия четырнадцатого урока и лимит тринадцатого с живым трафиком.
- /metrics на общем mux: promhttp.Handler() плюс бесплатные go_* и process_* метрики рантайма и процесса.

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

Оденьте notes-сервис из третьего урока в полный слой метрик и посмотрите на него глазами дежурного. Шаг первый: декларируйте четыре метрики урока — счётчик с метками procedure и code, гистограмму длительности с бакетами, датчик in-flight, счётчик сообщений стрима с direction — и зарегистрируйте их в init. Шаг второй: напишите metricsInterceptor со всеми тремя методами: WrapUnary с errors.As и двумя наблюдениями, WrapStreamingHandler с обёрткой countedConn, WrapStreamingClient как pass-through. Шаг третий: соберите mux с GET /metrics через promhttp.Handler(), GET /healthz и хендлером сервиса в порядке WithInterceptors(&metricsInterceptor{}, &authInterceptor{}). Шаг четвёртый — проверка трафиком: десяток успешных вызовов CreateNote и GetNote, вызов без токена, вызов с битым токеном, короткий стрим с отключением клиента посередине. Прочитайте /metrics curl-ом и найдите: серию с code=ok, две серии с code=unauthenticated и их разницу, ненулевые receive и send у стрима, go_goroutines рядом. Шаг пятый: поднимите локальный Prometheus со скрейпом раз в пять секунд и постройте три панели RAW-метода; запишите в заметки, какой бакет гистограммы первым перевалит за единицу — это подсказка для пересборки бакетов под ваш профиль.

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

### otelconnect или самописный интерсептор — что выбрать?

Начните с otelconnect — он даст трейсы и метрики за одну опцию и стандартные атрибуты, а exporter решит, куда они текут. Самописный слой с Prometheus оправдан, когда инфраструктура скрейпит /metrics напрямую и вам нужен контроль над именами, метками и бакетами; оба слоя живут вместе: otelconnect для распределённой картины, свои счётчики для дашбордов сервиса.

### Почему порядок WithInterceptors(&metricsInterceptor{}, &authInterceptor{}) именно такой?

Первый интерсептор — внешний слой луковицы: метрики должны оборачивать auth, чтобы видеть и отклонённые вызовы с кодом unauthenticated. Поменяйте порядок — и метрики увидят только прошедшие проверку, а всплеск попыток обхода станет невидимым. Это тот же контракт порядка, что для логов в седьмом и тринадцатом уроках: решите, кто видит отказы, и зафиксируйте порядок комментарием.

### Как считать размер сообщений для стримов?

Для unary доступен Content-Length входящего запроса в Connect-протоколе — его достаточно для гистограммы размеров. Для стримов рамка сообщения даёт длину на каждый кадр, но из интерсептора её достать нельзя — обёртка conn видит только успех и неудачу. Практичный путь: считать сообщения счётчиком, а байты оценивать на транспортном уровне или сжимать вопрос к порогу сжатия только для unary-трафика, где он и решает.
