---
title: "Логи для сбора: поля для Loki и ELK"
description: "Схема полей: логи для elk и Loki, json логи продакшен, уровни строкой, timestamp, request_id и многострочные ошибки в конвейере сбора сервиса."
course: "Логирование в Go: структурные логи на slog"
url: "https://golangmaster.ru/courses/slog/lessons/17-logi-dlya-sbora/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Логирование в Go: структурные логи на slog», урок 17 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/slog/lessons/17-logi-dlya-sbora/*

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

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

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

| Поле | Кто кладёт | Зачем сбору |
| --- | --- | --- |
| 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 с переносами: однострочная запись

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

> **Цена поля в ELK: mapping и кардинальность.** 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 и лимитом полей.

> **Песочница: экранирование и время на fmt и strconv.** Пакета 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. Как привести записи к конвенции?

- ✅ ReplaceAttr в HandlerOptions: переписать служебное поле level при выводе
- Писать уровень вручную в msg каждой записи и оставить поле как есть
- Сменить NewJSONHandler на NewTextHandler: там уровень сразу в нижнем регистре
- Считать запись с ERROR ошибкой парсинга и разбирать её отдельным правилом

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

### Проверка: многострочное значение

**Вопрос:** Ошибка содержит переносы строк. Что произойдёт при logger.Error("сбой", "err", err) во встроенном JSONHandler?

- ✅ Переносы экранируются в \n внутри строки: запись останется одной строкой JSON
- JSONHandler разобьёт запись на несколько строк по переносам
- Переносы будут удалены без следа, строки склеятся
- Запись не выйдет: перенос строки в атрибуте недопустим

Экранирование — часть формата: значение с переносами упаковывается в одну строку JSON, и коллектор получает одну запись. Проблемы начинаются при прямой печати через fmt и при паниках, которые пишут мимо Handler отдельными строками.

### Сколько строк уйдёт на stdout?

```go
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)
```

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

- ✅ Одна строка JSON: level:error и экранированные переносы внутри поля err
- Две строки: одна до переноса, вторая после
- Одна строка, но level останется ERROR: ReplaceAttr не трогает служебные поля
- Ошибка сериализации: перенос в значении атрибута

ReplaceAttr переписывает служебное поле level на значение error, а экранирование значений — работа самого Handler: переносы в err станут \n внутри одной строки JSON. Запись однострочная, конвейер получит один документ целиком.

### Соберите схему полей

Допишите: механизм переписывания служебных полей, ключ уровня и запись уровня Error.

```go
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, служебный ключ уровня — slog.LevelKey, запись уровня Error делает метод Error.

Правильные ответы в пропусках: `ReplaceAttr`, `Level`, `Error`

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

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

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

1. `opts := &slog.HandlerOptions{ReplaceAttr: rewriteLevel}`
2. `logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))`
3. `logger = logger.With("service", "billing", "env", "prod")`
4. `reqLog := logger.With("request_id", "r-2025-0314-17")`
5. `reqLog.Info("заказ принят", "order", "a-17")`
6. `reqLog.Error("оплата не прошла", "err", err)`

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

- **ReplaceAttr** — Опция HandlerOptions: переписывает служебные поля time, level, msg и атрибуты при выводе. Место для конвенции конвейера.
- **базовые поля записи** — time в RFC3339, level строкой, msg с сообщением — набор, который ждёт любой конвейер.
- **сквозные поля** — service, env, version через logger.With на старте; request_id через логгер на запрос. Идентичность кладёт код.
- **многострочные значения** — Встроенные Handler экранируют переносы: запись остаётся однострочной. Разъезжаются fmt, легаси-печать и паники.
- **кардинальность лейблов** — В Loki уникальные значения вроде request_id не кладут в лейблы: новый поток на каждое значение давит хранилище.
- **mapping в ELK** — Каждое имя поля попадает в mapping индекса; динамические имена выедают лимит полей и взрывают индекс.

Схема собрана: базовые поля записи, сквозные поля сервиса, уровень и время по конвенции конвейера, многострочные значения под контролем экранирования. Дальше по курсу: [пятый урок про TextHandler и JSONHandler](/courses/slog/lessons/05-text-vs-json/) остаётся фундаментом формата, [восьмой урок про With](/courses/slog/lessons/08-with-predlog/) показывает сборку полей сервиса, [пятнадцатый](/courses/slog/lessons/15-request-id/) — склейку строк запроса, [девятнадцатый](/courses/slog/lessons/19-sekrety-v-logah/) — что из полей нельзя выпускать наружу, а [двадцать четвёртый](/courses/slog/lessons/24-logi-na-prode/) — как journald и Docker отдают записи конвейеру. Карта курса — на [странице курса](/courses/slog/).

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

- Запись несёт 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.
