Содержание урока · 13 разделов
- Три сигнала и их роли
- Корреляция: trace_id в полях лога
- Что будет, если клиент подделал traceparent
- OpenTelemetry: лог-мост обзор
- Sampler: цена наблюдаемости
- Уровни как контракт для алертов
- Алерты по логам: Error-бёрсты
- SLO-мышление: зачем всё это
- Минимальный стек для старта
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Пятница, вечер: график кода 500 ползёт вверх, дежурный открывает логи — и не может связать их с запросами: у записей нет идентификаторов трейса, у трейсов нет ссылок на логи, у метрик нет вообще ничего, кроме числа. Это антипример наблюдаемости: сигналы есть, а ответа на вопрос «что сломалось и с чем» они не дают. Наблюдаемость — свойство системы отвечать на новые вопросы без новых деплоев, и держится она на связке трёх сигналов. Логи — события с деталями: что именно произошло в этом запросе. Метрики — агрегаты: сколько, как часто, с какой латентностью. Трейсы — путь запроса через сервисы. Задача урока — соединить то, что уже умеет ваш zap из прошлых уроков, с остальными двумя сигналами и получить систему, где инцидент разбирается, а не переживается.
Три сигнала и их роли#
| Сигнал | Отвечает на вопрос | Форма | Цена |
|---|---|---|---|
| Логи | что именно произошло | события с полями | высокая: каждая запись |
| Метрики | сколько и как часто | числа и гистограммы | низкая: агрегаты |
| Трейсы | где запрос потерял время | спаны с длительностями | средняя: сэмплирование |
Корреляция: trace_id в полях лога#
Ключ к связке — идентификатор, общий для всех сигналов. В трейсах это trace_id: hex-строка из 32 символов, которая живёт в span-контексте и путешествует между сервисами в заголовке traceparent. Логирующий middleware достаёт контекст пропагатором OpenTelemetry, берёт SpanContext и кладёт trace_id в логгер через With — дальше каждая запись запроса несёт его как обычное поле. Не путайте с request_id из урока про request id: тот генерируется вашим приложением для локальной хронологии, trace_id приходит из системы трассировки и соединяет сервисы между собой. В зрелом проекте живут оба: request_id отвечает за порядок внутри сервиса, trace_id — за путь между ними, и в записи они сосуществуют без конфликтов. Достаётся идентификатор строго из контекста: единственный источник правды — span, который принесла входящая цепочка, а не придуманное на месте значение.
Посредник с trace_id
GOfunc TraceLogger(base *zap.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := otel.GetTextMapPropagator().Extract(
r.Context(), propagation.HeaderCarrier(r.Header),
)
sc := trace.SpanFromContext(ctx).SpanContext()
logger := base
if sc.IsValid() {
logger = base.With(
zap.String("trace_id", sc.TraceID().String()),
zap.String("span_id", sc.SpanID().String()),
)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}Пропагатор читает traceparent из заголовков и вписывает span-контекст в ctx; SpanFromContext достаёт его обратно. Пока трейса нет — заголовок потерян или трассировка выключена, — IsValid отсекает нулевой SpanContext, и записи уходят без trace_id вместо пустышек. Поля прилипают к логгеру той же механикой With, что и у request_id.
Проверка: откуда trace_id
Откуда middleware берёт trace_id входящего запроса?
Что запишется без трейса?
Прочитайте код и выберите, что он выведет:
sc := trace.SpanFromContext(context.Background()).SpanContext()
fmt.Println(sc.TraceID().String())
fmt.Println(sc.IsValid())Что будет, если клиент подделал traceparent#
Заголовок приходит от клиента, и пропагатор примет любой валидный по форме trace_id: не совпал с форматом — извлечение вернёт пустой контекст и сработает проверка IsValid; совпал — поле уйдёт в лог как есть. Это не уязвимость, а договорённость: trace_id — данные, а не доверенное значение, и фильтровать записи по нему безопасно только внутри одного инцидента. Практические следствия: маскировать поле не нужно — hex без секретов, но строить по нему авторизацию нельзя; длина фиксирована, раздуть запись им не получится; сервис, начинающий трейс первым, обязан генерировать новый идентификатор, а не принимать нулевой от клиента — IsValid и это ловит.
OpenTelemetry: лог-мост обзор#
Логи в OpenTelemetry — третий сигнал рядом с трейсами и метриками: у SDK есть LoggerProvider, у API — запись с телом, уровнем и атрибутами. Прикладной код при этом не переписывается: есть мосты, которые берут записи у существующего логгера. Для zap такой мост — otelzap из contrib: он оборачивает ядро и пересылает записи в LoggerProvider, дальше их собирает коллектор и маршрутизирует в хранилище. Обратная дорога тоже есть: SDK умеет приписывать трейсовый контекст событиям лога, поэтому поле trace_id может прийти как от вашего With, так и от моста. Здесь обзор сознательно короткий: связка «SDK, коллектор, экспортёр» — отдельная дисциплина со своей документацией, и для первого знакомства достаточно локального Go, коллектора рядом и пары записей в консоль.
Мост otelzap в два вызова
GOimport (
"go.opentelemetry.io/contrib/bridges/otelzap"
"go.opentelemetry.io/otel/log/global"
)
provider := global.GetLoggerProvider()
core := otelzap.NewCore("tasks", otelzap.WithLoggerProvider(provider))
logger := zap.New(core)
logger.Info("order accepted", zap.String("id", "a-1"))NewCore строит zapcore.Core, который отправляет записи в LoggerProvider: имя instrumentation-библиотеки — tasks, поля становятся атрибутами. global.GetLoggerProvider возвращает провайдера, установленного SDK; без него мост тихо ставит no-op, и цена вызова стремится к нулю. В проде такое ядро обычно комбинируют через NewTee с файловым выводом.
Sampler: цена наблюдаемости#
Полный лог каждого запроса — роскошь, которая на гигантском трафике превращается в отдельную статью расходов, и трейсы решают ту же задачу сэмплированием: head-сэмплер режет поток на входе по вероятности, tail-сэмплер в коллекторе отбирает интересные — с ошибками и большими латентностями. У zap своя механика прореживания: NewSamplerWithOptions пропускает первые N однотипных записей за тик и дальше одну на тик — именно она включена в production-пресет. Гранулярность у сэмплирования и трейсов разная: логи прореживаются по сообщению, трейсы — по запросу целиком; идеи родственные, слои разные, и одно не заменяет другое. Дропы стоит видеть: хук SamplerHook отдаёт метрику отброшенных записей, и её график — честная цена прореживания, а не абстрактное «что-то потерялось».
Production-пресет включает сэмплер: первые сто одинаковых сообщений за тик пишутся, дальше — одна запись на тик. Тысяча одинаковых ошибок за секунду превращается в сотню с небольшим записей: алерт по факту бёрста сработает, а вот точное число ошибок по логам не посчитать — цифра занижена на порядок. Счёт живёт в метриках, а хук SamplerHook отдаёт число дропнутых записей; когда график дропов стоит на дашборде рядом с трафиком, занижение перестаёт быть сюрпризом.
Проверка: кто будит дежурного
Кто обычно триггерит алерт, а кто подтверждает причину?
Уровни как контракт для алертов#
Связка уровней с алертами — договорённость, заключённая заранее, а не спонтанно в три ночи. Error — сигнал аварии: на него смотрит правило бёрста. Warn — подозрительное поведение: ретраи, рой 404, медленные запросы у порога; на Warn стреляет только накопительный сигнал, и канал — чат, а не пейджер. Fatal — процесс умирает, и алерт ставят на отсутствие сигналов: метрику живости, а не лог, потому что последняя запись Fatal до тёплой системы сбора может не доехать вовсе. Info — не сигнал, а материал: по нему считают трафик и долю ошибок. У такого контракта есть побочный эффект: ревью уровня записи становится ревью её цены в алертах, и вечные споры «Info или Warn» заканчиваются быстрее, потому что уровень теперь стоит денег.
Алерты по логам: Error-бёрсты#
Бывают события, у которых нет метрики: редкая ошибка интеграции, аномальный код ответа, сообщение уровня Fatal. Для них работают алерты по потоку логов: счётчик Error за окно, порог, окно тишины. Правило реакции простое: одиночная ошибка — не инцидент, бёрст — инцидент, и порог подбирается по истории, а не по желанию «наконец-то узнавать всё». Тонкая настройка — дедупликация: пятьсот одинаковых записей из одного цикла — это один инцидент, и алерт должен стрелять один раз; отпечаток собирают из имени сервиса, сообщения и ключевых полей. Полный конвейер — централизованное хранилище, правила поверх него и канал с эскалацией — тема следующего урока, а здесь важно направление: логи не только читают глазами, на них ещё и сигналят.
Правило бёрста как правило поверх логов
GO# Loki ruler: бёрст ошибок сервиса задач
groups:
- name: tasks-errors
rules:
- alert: TaskErrorBurst
expr: sum(increase({service="tasks", level="error"}[5m])) > 20
for: 2m
labels:
severity: page
annotations:
summary: "20+ ошибок за 5 минут в tasks"Правило считает записи уровня error за пять минут и уходит в пейджинг только после двух минут непрерывного превышения: одиночные всплески гасятся фазой for. Лог-запрос берёт поля, которые zap записал в JSON, — service и level. Порог 20 и окно 5m — стартовые значения: их правят по истории инцидентов, а не по вкусу. Если метки level у записей нет — правило молчит: sum по пустому набору вернёт ноль, и алерт не стрельнёт вовсе. Отсюда привычка сверять селектор с реальными полями JSON: пробный запрос в UI Loki занимает минуту и экономит вечер тишины.
SLO-мышление: зачем всё это#
Наблюдаемость имеет смысл, когда есть цель. SLO фиксирует обещание уровня сервиса: например, 99,9% запросов списков задач отвечают быстрее 300 миллисекунд за месяц. Остаток — error budget, бюджет ошибок, который можно потратить на эксперименты и релизы. Метрики следят за бюджетом и триггерят разговор, когда он тает; логи — материал разбора, куда именно ушла доля. Из этого растут решения всего курса: Error — для аварий, Warn — для подозрительного, тишину в штатном сценарии проверяют тесты, сэмплинг экономит бюджет хранения. Алерты на каждую запись Error ломают модель: доля ошибок — дело бюджета, а не каждой строчки. Дежурный просыпается на тренд, а не на единичный сбой, и тем самым сохраняет способность вообще просыпаться на настоящие аварии. Отсюда и отказ алертить каждую строчку: строчка — материал, тренд — сигнал.
Если сигнал сводится к числу — считайте счётчиком у метрик: он дешевле записи, агрегируется без парсинга и живёт в Prometheus. Лог нужен там, где важен контекст: поля, идентификаторы, деталь. Парсинг логов ради графика — самый дорогой способ посчитать.
Минимальный стек для старта#
- Prometheus: метрики и алерт-правила, дешёвые счётчики рядом с логами.
- OTel Collector: приём трейсов и лог-мостов, маршрутизация в хранилища.
- Loki или ELK: централизованный поиск по полям JSON.
- Дашборд латентности и ошибок рядом с топом сообщений логов.
- Канал алертов с эскалацией: пейджинг на бёрсты, чат на остальное.
- График SamplerHook: доля дропнутых записей — цена сэмплинга на одном экране с трафиком.
Соберите извлечение идентификатора
Восстановите посредник: извлечение, проверка, поле.
Соберите связку запрос — лог
Расставьте шаги посредника по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: наблюдаемость
Что дальше по курсу#
Хранение, поиск и полный конвейер алертов — тема урока про эксплуатацию: там Loki и ClickHouse разбираются по одной строке и появляются retention и 152-ФЗ. Прореживание потока в цифрах — урок про сэмплирование, разделение request_id и trace_id — урок про request id. Карта курса — на странице курса zap.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Наблюдаемость — способность отвечать на новые вопросы без новых деплоев.
- Логи говорят «что», метрики — «сколько», трейсы — «где по пути».
- trace_id приходит из span-контекста через пропагатор и уходит в поля With.
- Без IsValid в записях появляются нулевые идентификаторы-пустышки.
- Мост otelzap отправляет записи zap в LoggerProvider, не меняя энкодер.
- Метрики триггерят, логи подтверждают: алерт — на бёрст, не на строчку.
- SLO переводит уровни и алерты из вкусовщины в бюджет ошибок.
Домашнее задание
Добавьте в свой сервис из middleware-уроков посредник TraceLogger: пропагатор, IsValid, поле trace_id. Проверьте тестом с observer, что записи запроса с заголовком traceparent несут поле, а без него — не несут ничего. Затем посчитайте за день долю Error-записей и напишите правило бёрста с порогом по этой доле. После этого включите на логгере сэмплер и повторите бёрст: сравните число записей в хранилище с числом вызовов в коде — разница и есть цена прореживания.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли полноценный OTel SDK, чтобы просто писать trace_id в логи?
Нет: пропагатор и trace-пакет работают и без экспортёров — контекст извлекается, IsValid проверяется, поле пишется. SDK нужен, когда трейсы должны куда-то улетать; логи с trace_id полезны и без них.
Что приоритетнее внедрить первым — метрики или трейсы?
Метрики: они дёшевы, сразу дают графики и алерты. Трейсы дороже и нужнее при росте числа сервисов: один сервис объясняется логами, цепочка — трейсом.
Можно ли алертить по Warn-бёрстам?
Можно и иногда нужно — например, по ретраям шлюза. Но Warn шумнее Error, поэтому порог выше, окно длиннее, а канал — чат, не пейджер.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Colly: парсинг и веб-скрапинг на Go
Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.
- Писать скраперы на Colly от первого Visit до обхода целого сайта
- Вынимать данные через CSS-селекторы и складывать в структуры Go
- Настраивать темп: задержки, параллелизм, лимиты на домен
- Работать с капризными сайтами: заголовки, cookies, прокси