Содержание урока · 9 разделов
На проде записи читает не человек, а конвейер: коллектор забирает строки, парсит их, кладёт в хранилище, и уже там дежурные ищут ошибки по полям и строят графики. От того, как оформлены поля, зависит, получится ли отфильтровать ошибки одного сервиса за минуту или придётся читать сырые строки глазами. Соглашение между приложением и конвейером стоит закрепить до выхода на прод: какая схема полей, в каком регистре уровни, что считается временем записи и как ведут себя значения с переносами строк. Встроенные обработчики slog дают на это все рычаги — от ReplaceAttr до групп и предзаданных полей — и в этом уроке мы пройдём по всей схеме: базовые поля записи, сквозные поля сервиса, уровни строкой, timestamp и многострочные ошибки, которые умудряются ломать даже настроенные конвейеры.
Логи для elk и Loki: базовая схема полей#
Каждая запись JSONHandler несёт три служебных поля: time в RFC3339 с миллисекундами и зоной процесса, level строкой вроде INFO или ERROR и msg с сообщением, а дальше — атрибуты плоскими полями. Этой схемы достаточно конвейеру для базы: время даёт порядок и графики, уровень — фильтры и алерты, сообщение — контекст для человека. Системы различаются подробностями. Loki хранит записи потоками по лейблам и парсит поля в момент запроса, поэтому важна кардинальность лейблов: уникальные значения вроде request_id в лейблы не кладут. Elasticsearch складывает записи как документы и индексирует каждое поле, поэтому важна стабильность имён: каждое новое имя поля попадает в mapping индекса. Уровни по умолчанию печатаются в верхнем регистре — INFO, ERROR — и часть конвейеров ожидает нижний: это поправимо на стороне приложения через ReplaceAttr, и менять лучше у источника, чтобы конвенция была видна в коде и в ревью. Время записывается в зоне процесса: контейнер живёт в UTC, машина разработчика — в локальной зоне, и разнобой зон в одном хранилище стоит исправить на старте, а не во время инцидента.
ReplaceAttr: уровень строкой по конвенции
GOpackage main
import (
"log/slog"
"os"
"strings"
)
func main() {
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.LevelKey {
a.Value = slog.StringValue(strings.ToLower(a.Value.String()))
}
return a
},
})
logger := slog.New(h)
logger.Error("оплата отклонена", "order", "a-17")
}ReplaceAttr вызывается для каждого служебного поля и атрибута при выводе: на вход приходят группы и атрибут, на выход — атрибут, который попадёт в запись. Здесь уровень переводится строкой в нижний регистр, и на stdout уйдёт level:error вместо level:ERROR. Честная оговорка: ReplaceAttr срабатывает на каждый вызов записи, поэтому без тяжёлых вычислений внутри — перевод строки в нижний регистр стоит копейки, а вот сбор поля на лету уже заметен на горячем пути.
Общие поля: service, env, request_id#
В хранилище логов десятки сервисов пишут в один поток, и без общих полей записи неразличимы. Минимальный набор идентичности собирается один раз на старте: service с именем сервиса, env с окружением, version с версией сборки — всё через logger.With, чтобы каждое поле стояло в каждой записи без повторения в вызовах. Сквозные поля запроса добавляются логгером на запрос: request_id из middleware, идентификатор пользователя, имя обработчика — механика разбиралась в уроке про With и в уроке про request ID. Имена полей — договорённость, а не истина: часть команд живёт в snake_case, часть в camelCase, и оба варианта работают, пока выбор одинаков во всех сервисах компании. Служебные имена трогать без нужды не стоит: msg, level и time знакомы каждому конвейеру, а переименование через ReplaceAttr добавляет ещё одно место, где записи расходятся с примерами документации. В ELK существуют и зарезервированные имена вроде @timestamp — маппинг msg и time на них настраивается на стороне конвейера, и это ещё один аргумент за стабильную схему: чем меньше сюрпризов в именах, тем проще конфиг сбора.
Схема полей для конвейера сбора#
| Поле | Кто кладёт | Зачем сбору |
|---|---|---|
| time | Handler | порядок записей, графики по времени, корреляция инцидентов |
| level | Handler | фильтры ошибок, алерты, счётчики по уровням |
| msg | Handler | человекочитаемое сообщение для чтения глазами |
| service | logger.With на старте | поиск по сервису в общем потоке хранилища |
| env | logger.With на старте | разграничение прода и стендов в одном хранилище |
| request_id | логгер на запрос | склейка строк одного запроса через слои сервиса |
Многострочные ошибки: где конвейер ломается#
Коллекторы режут поток по строкам: одна строка — одна запись. Встроенные обработчики slog держат контракт: переносы внутри значения экранируются — JSONHandler превращает их в \n внутри строки JSON, TextHandler закавычивает значение целиком, — и запись остаётся однострочной. Ломается там, где строки пишет не Handler. Первое место — паники Go: рантайм печатает стек в stderr сам, мимо slog, отдельными строками, и каждая строка становится независимой записью без msg, уровня и полей. Второе — легаси-пути через fmt.Println и log.Printf, куда уезжает ошибка со стеком через %+v: та же расползшаяся запись. Третье — собственный Handler без экранирования значений. Решение у всех трёх общее: многострочное значение логируется полем, а не печатается напрямую, а паника ловится recover-обёрткой и логируется slog.Error со стеком в поле — обработчики сами позаботятся об экранировании. Тогда запись со стеком остаётся одной строкой, поиск находит её целиком, а счётчик ошибок не завышается кадрами стека.
Err с переносами: однострочная запись
GOerr := errors.New("выгрузка не сошлась:\n шаг 1: timeout\n шаг 2: connection refused")
logger.Error("выгрузка не прошла", "err", err)JSONHandler упакует значение с переносами в одну строку JSON: поле err получит текст с экранированными \n, и запись не разъедется на три строки. TextHandler поступит так же силами кавычек. А вызов fmt.Println("выгрузка:", err) выпустит три строки — каждая уедет в хранилище отдельной записью без полей. Разница между полем с экранированием и прямой печатью — это разница между одним найденным документом и тремя обрывками.
Кейс: сервис логировал через slog, но паники не ловил — рантайм печатал стек в stderr, и Loki превращал каждый кадр в отдельную запись. Поиск по имени функции находил сорок строк: половина без уровня и msg, дашборд ошибок считал кадры стека как самостоятельные ошибки, и счётчик завышался в двадцать раз. Разбор занял вечер только потому, что поиск выдавал обрывки без контекста. Фикс: recover в middleware HTTP-обработчиков, стек и значение паники — полями в slog.Error, а не прямой печатью рантайма; запись со стеком стала одной строкой, а дашборд — правдой. Правило простое: всё, что печатает стек, должно проходить через поле с экранированием.
Спорное: обогащать поля в приложении или на коллекторе#
Мнение, которое стоит оспорить на ретро: схема полей — дело конвейера, а приложение должно печатать минимум и не знать про сбор. За конвейерную позицию: коллектор видит метаданные окружения — pod, namespace, образ — и добавляет их без единой строчки кода; переименование полей для конкретного хранилища делается в конфиге парсера. Против: схема размазывается между кодом и конфигом, и вопрос «почему в записи нет service» ведёт сначала в конфиг promtail, потом в код; тестировать сборку полей на стороне коллектора сложнее, чем в Go-тестах из двадцатого урока; а переезд конвейера выносит схему на переписывание заново. Компромисс, который прижился: идентичность и смысл — в приложении, инфраструктура — на коллекторе. service, env, version, request_id и бизнес-поля кладёт код, потому что их никто, кроме кода, не знает; pod, namespace и метки платформы добавляет конвейер, потому что у него они уже есть. Ошибка обратного рода — дублировать одно и то же поле на обеих сторонах с разными значениями: разнобой в схеме найдётся только на дашборде, когда графики перестанут сходиться.
Elasticsearch индексирует каждое поле, и mapping индекса растёт с каждым новым именем: лимит полей по умолчанию — тысяча, и динамические ключи вроде id заказа в имени поля его выедают быстро. Уникальные значения кладут в значения полей, а не в имена: поле order со строковым значением — норм, поле order-a-17 — путь к переполнению mapping. Loki смотрит в другую сторону: поток разбивается по лейблам, и лейбл с высокой кардинальностью — request_id, уникальный на каждый запрос, — создаёт новый поток на каждое значение, что давит на хранилище и замедляет запросы. Уникальные значения в Loki живут в самих записях и достаются json-парсером в запросе. Цена поля различается между системами, и схема должна это учитывать.
Чек-лист схемы для сбора#
- time, level, msg — базовые поля записи; согласуйте регистр уровня и зону времени с конвейером.
- service, env, version через logger.With на старте: идентичность кладёт код.
- request_id через логгер на запрос, а не повторением в каждом вызове.
- Многострочные значения — только полями со встроенным экранированием; fmt и прямая печать разъезжаются.
- Паники ловит recover-обёртка: стек уходит полем в slog.Error.
- Loki: уникальные значения не в лейблы; ELK: следите за размером mapping и лимитом полей.
Пакета log/slog в песочнице нет — там работают fmt, strings, strconv, math, sort, errors и time. Механику схемы она разминает честно: возьмите strconv.Quote("шаг 1\nшаг 2") и посмотрите, как переносы превращаются в экранированные \n внутри одной строки — это то, что делает TextHandler со значениями; соберите строку JSON вручную через fmt.Printf и сравните с наивной склейкой без экранирования. Таймстамп разминается на time.Now().Format(time.RFC3339) — тот же формат, что в поле time записи. Полная схема полей с ReplaceAttr запускается локально через go run.
Проверка: уровень по конвенции
Конвейер ожидает уровень строкой в нижнем регистре, а JSONHandler печатает ERROR. Как привести записи к конвенции?
Проверка: многострочное значение
Ошибка содержит переносы строк. Что произойдёт при logger.Error("сбой", "err", err) во встроенном JSONHandler?
Сколько строк уйдёт на stdout?
Прочитайте код и выберите, что он выведет:
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.LevelKey {
a.Value = slog.StringValue("error")
}
return a
},
})
logger := slog.New(h)
err := errors.New("шаг 1: таймаут\nшаг 2: отказ")
logger.Error("выгрузка не прошла", "err", err)Соберите схему полей
Допишите: механизм переписывания служебных полей, ключ уровня и запись уровня Error.
Логгер для конвейера по строкам
Расставьте по порядку: опции с ReplaceAttr, создание обработчика, поля сервиса, логгер на запрос, записи.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: схема полей для сбора
Схема собрана: базовые поля записи, сквозные поля сервиса, уровень и время по конвенции конвейера, многострочные значения под контролем экранирования. Дальше по курсу: пятый урок про TextHandler и JSONHandler остаётся фундаментом формата, восьмой урок про With показывает сборку полей сервиса, пятнадцатый — склейку строк запроса, девятнадцатый — что из полей нельзя выпускать наружу, а двадцать четвёртый — как journald и Docker отдают записи конвейеру. Карта курса — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Запись несёт time в RFC3339, level строкой и msg; конвейер строит на них фильтры, алерты и графики.
- ReplaceAttr — штатное место для конвенции: регистр уровня, формат времени, переименование служебных полей.
- Идентичность кладёт код: service, env, version через logger.With, request_id через логгер на запрос.
- Встроенные Handler экранируют переносы: многострочное значение остаётся полем в однострочной записи.
- Паники и fmt печатают мимо Handler: стек разъезжается по записям без полей — ловить recover и логировать полем.
- Цена поля различается: ELK растит mapping на каждое имя, Loki давится кардинальностью лейблов.
Домашнее задание
Соберите логгер с ReplaceAttr, который приводит уровень к нижнему регистру, и напишите запись с ошибкой, содержащей переносы строк: убедитесь, что на stdout ушла одна строка JSON. Затем поднимите тот же вызов через fmt.Println и посчитайте, сколько отдельных записей конвейер получил бы вместо одной. Запишите схему полей своего сервиса: базовые, сквозные, инфраструктурные — и отметьте, кто кладёт каждое.
Личные заметкиПусто
Вопросы и ответы по уроку
Какие поля класть обязательно, если конвейер только планируется?
time, level и msg дают базу уже из обработчика; service и env кладут через logger.With на старте, request_id — логгером на запрос. Дальше схема растёт от реальных вопросов дежурных: поиск по пользователю, версии сборки, имени обработчика — каждое поле отвечает на конкретный вопрос, а не на гипотетический.
Loki или ELK — меняет ли это схему полей?
Набор полей почти одинаков, различается цена: в ELK каждое имя поля попадает в mapping индекса, поэтому имена стабильные и без динамических ключей; в Loki лейблы с высокой кардинальностью создают потоки, поэтому уникальные значения живут в записях, а не в лейблах. JSON-записи подходят обоим.
Стоит ли переименовывать time и msg под требования хранилища?
Технически это делается тем же ReplaceAttr, но каждое переименование — место, где записи расходятся с документацией и чужими конвейерами. Чаще выгоднее оставить стандартные имена и настроить маппинг на стороне коллектора: конвенция у источника дешевле, если она совпадает с дефолтом slog.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Уровни по окружению: debug в dev, info в прод
- Секреты в логах: токены, пароли и ПДн
- Тестирование логов: capture и утверждения
- Логи и метрики: связка с Prometheus
- Куда пишут логи: stdout, файл и ротация
- Сквозные поля: request ID через слои
- Производительность: Enabled и горячий путь
- Конкурентность: один логгер на сервис
- Все курсы по Go
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY