Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~18 мин +20 XP

Логи для сбора: поля для Loki и ELK

Готовые логи для elk и Loki начинаются не с конвейера, а со схемы полей, которую приложение кладёт в каждую запись. JSONHandler печатает time, level, msg и атрибуты — но конвейер сбора живёт по своим конвенциям: регистр уровня, формат времени, имена сквозных полей и поведение с многострочными значениями. Разберём базовую схему, настройку служебных полей через ReplaceAttr и места, где записи ломаются на пути в хранилище.

Содержание урока · 9 разделов

На проде записи читает не человек, а конвейер: коллектор забирает строки, парсит их, кладёт в хранилище, и уже там дежурные ищут ошибки по полям и строят графики. От того, как оформлены поля, зависит, получится ли отфильтровать ошибки одного сервиса за минуту или придётся читать сырые строки глазами. Соглашение между приложением и конвейером стоит закрепить до выхода на прод: какая схема полей, в каком регистре уровни, что считается временем записи и как ведут себя значения с переносами строк. Встроенные обработчики slog дают на это все рычаги — от ReplaceAttr до групп и предзаданных полей — и в этом уроке мы пройдём по всей схеме: базовые поля записи, сквозные поля сервиса, уровни строкой, timestamp и многострочные ошибки, которые умудряются ломать даже настроенные конвейеры.

Логи для elk и Loki: базовая схема полей#

Каждая запись JSONHandler несёт три служебных поля: time в RFC3339 с миллисекундами и зоной процесса, level строкой вроде INFO или ERROR и msg с сообщением, а дальше — атрибуты плоскими полями. Этой схемы достаточно конвейеру для базы: время даёт порядок и графики, уровень — фильтры и алерты, сообщение — контекст для человека. Системы различаются подробностями. Loki хранит записи потоками по лейблам и парсит поля в момент запроса, поэтому важна кардинальность лейблов: уникальные значения вроде request_id в лейблы не кладут. Elasticsearch складывает записи как документы и индексирует каждое поле, поэтому важна стабильность имён: каждое новое имя поля попадает в mapping индекса. Уровни по умолчанию печатаются в верхнем регистре — INFO, ERROR — и часть конвейеров ожидает нижний: это поправимо на стороне приложения через ReplaceAttr, и менять лучше у источника, чтобы конвенция была видна в коде и в ревью. Время записывается в зоне процесса: контейнер живёт в UTC, машина разработчика — в локальной зоне, и разнобой зон в одном хранилище стоит исправить на старте, а не во время инцидента.

ReplaceAttr: уровень строкой по конвенции

GO
package 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 на них настраивается на стороне конвейера, и это ещё один аргумент за стабильную схему: чем меньше сюрпризов в именах, тем проще конфиг сбора.

Схема полей для конвейера сбора#

ПолеКто кладётЗачем сбору
timeHandlerпорядок записей, графики по времени, корреляция инцидентов
levelHandlerфильтры ошибок, алерты, счётчики по уровням
msgHandlerчеловекочитаемое сообщение для чтения глазами
servicelogger.With на стартепоиск по сервису в общем потоке хранилища
envlogger.With на стартеразграничение прода и стендов в одном хранилище
request_idлоггер на запроссклейка строк одного запроса через слои сервиса

Многострочные ошибки: где конвейер ломается#

Коллекторы режут поток по строкам: одна строка — одна запись. Встроенные обработчики slog держат контракт: переносы внутри значения экранируются — JSONHandler превращает их в \n внутри строки JSON, TextHandler закавычивает значение целиком, — и запись остаётся однострочной. Ломается там, где строки пишет не Handler. Первое место — паники Go: рантайм печатает стек в stderr сам, мимо slog, отдельными строками, и каждая строка становится независимой записью без msg, уровня и полей. Второе — легаси-пути через fmt.Println и log.Printf, куда уезжает ошибка со стеком через %+v: та же расползшаяся запись. Третье — собственный Handler без экранирования значений. Решение у всех трёх общее: многострочное значение логируется полем, а не печатается напрямую, а паника ловится recover-обёрткой и логируется slog.Error со стеком в поле — обработчики сами позаботятся об экранировании. Тогда запись со стеком остаётся одной строкой, поиск находит её целиком, а счётчик ошибок не завышается кадрами стека.

Err с переносами: однострочная запись

GO
err := errors.New("выгрузка не сошлась:\n  шаг 1: timeout\n  шаг 2: connection refused")

logger.Error("выгрузка не прошла", "err", err)

JSONHandler упакует значение с переносами в одну строку JSON: поле err получит текст с экранированными \n, и запись не разъедется на три строки. TextHandler поступит так же силами кавычек. А вызов fmt.Println("выгрузка:", err) выпустит три строки — каждая уедет в хранилище отдельной записью без полей. Разница между полем с экранированием и прямой печатью — это разница между одним найденным документом и тремя обрывками.

Спорное: обогащать поля в приложении или на коллекторе#

Мнение, которое стоит оспорить на ретро: схема полей — дело конвейера, а приложение должно печатать минимум и не знать про сбор. За конвейерную позицию: коллектор видит метаданные окружения — pod, namespace, образ — и добавляет их без единой строчки кода; переименование полей для конкретного хранилища делается в конфиге парсера. Против: схема размазывается между кодом и конфигом, и вопрос «почему в записи нет service» ведёт сначала в конфиг promtail, потом в код; тестировать сборку полей на стороне коллектора сложнее, чем в Go-тестах из двадцатого урока; а переезд конвейера выносит схему на переписывание заново. Компромисс, который прижился: идентичность и смысл — в приложении, инфраструктура — на коллекторе. service, env, version, request_id и бизнес-поля кладёт код, потому что их никто, кроме кода, не знает; pod, namespace и метки платформы добавляет конвейер, потому что у него они уже есть. Ошибка обратного рода — дублировать одно и то же поле на обеих сторонах с разными значениями: разнобой в схеме найдётся только на дашборде, когда графики перестанут сходиться.

Чек-лист схемы для сбора#

  • time, level, msg — базовые поля записи; согласуйте регистр уровня и зону времени с конвейером.
  • service, env, version через logger.With на старте: идентичность кладёт код.
  • request_id через логгер на запрос, а не повторением в каждом вызове.
  • Многострочные значения — только полями со встроенным экранированием; fmt и прямая печать разъезжаются.
  • Паники ловит recover-обёртка: стек уходит полем в slog.Error.
  • Loki: уникальные значения не в лейблы; ELK: следите за размером mapping и лимитом полей.

Проверка: уровень по конвенции

Конвейер ожидает уровень строкой в нижнем регистре, а 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.

h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ : func(groups []string, a slog.Attr) slog.Attr { if a.Key == slog.Key { a.Value = slog.StringValue(strings.ToLower(a.Value.String())) } return a }, }) logger.With("service", "billing", "env", "prod").("оплата не прошла", "order", "a-17")

Логгер для конвейера по строкам

Расставьте по порядку: опции с ReplaceAttr, создание обработчика, поля сервиса, логгер на запрос, записи.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: схема полей для сбора

Термин

ReplaceAttr

нажмите, чтобы перевернуть
Определение

Опция HandlerOptions: переписывает служебные поля time, level, msg и атрибуты при выводе. Место для конвенции конвейера.

1 / 6

Схема собрана: базовые поля записи, сквозные поля сервиса, уровень и время по конвенции конвейера, многострочные значения под контролем экранирования. Дальше по курсу: пятый урок про 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 и посчитайте, сколько отдельных записей конвейер получил бы вместо одной. Запишите схему полей своего сервиса: базовые, сквозные, инфраструктурные — и отметьте, кто кладёт каждое.

Личные заметкиПусто
Хранятся только в вашем браузере (Slog · Логи для сбора: поля для Loki и ELK)

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

Какие поля класть обязательно, если конвейер только планируется?

time, level и msg дают базу уже из обработчика; service и env кладут через logger.With на старте, request_id — логгером на запрос. Дальше схема растёт от реальных вопросов дежурных: поиск по пользователю, версии сборки, имени обработчика — каждое поле отвечает на конкретный вопрос, а не на гипотетический.

Loki или ELK — меняет ли это схему полей?

Набор полей почти одинаков, различается цена: в ELK каждое имя поля попадает в mapping индекса, поэтому имена стабильные и без динамических ключей; в Loki лейблы с высокой кардинальностью создают потоки, поэтому уникальные значения живут в записях, а не в лейблах. JSON-записи подходят обоим.

Стоит ли переименовывать time и msg под требования хранилища?

Технически это делается тем же ReplaceAttr, но каждое переименование — место, где записи расходятся с документацией и чужими конвейерами. Чаще выгоднее оставить стандартные имена и настроить маппинг на стороне коллектора: конвенция у источника дешевле, если она совпадает с дефолтом slog.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

pgx25 уроков~12 чпрактика25 уроков уже доступны

PostgreSQL в Go: драйвер pgx с нуля до продакшена

Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.

  • Подключаться к PostgreSQL через pgx и pgxpool
  • Сканировать строки в структуры и справляться с NULL
  • Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
  • Проводить транзакции и массовые вставки через Batch и COPY