Структурированное логирование в Go с использованием slog для наблюдаемости и оповещений
Поисковые JSON-журналы, связанные с трассировками.
Журналы (логи) — это интерфейс отладки, который вы все еще можете использовать, когда система «горит». Проблема в том, что обычные текстовые журналы быстро устаревают: как только вам нужна фильтрация, агрегация и оповещения, вы начинаете парсить предложения.

Структурированное логирование — это antidote (противоядие). Оно превращает каждую строку лога в небольшое событие с устойчивыми полями, поэтому инструменты могут надежно искать и агрегировать данные. Чтобы узнать, как журналы связаны с метриками, дашбордами и оповещениями в более широком стеке, см. Руководство по наблюдаемости: мониторинг, метрики, Prometheus и Grafana.
Что такое структурированное логирование и почему оно масштабируется
Структурированное логирование — это логирование, при котором запись — это не просто строка, а сообщение плюс типизированные атрибуты «ключ-значение». Идея проста в лучшем смысле этого слова: как только логи становятся машиночитаемыми, инцидент перестает быть соревнованием по использованию grep.
Быстрое сравнение:
Обычный текст (ориентирован на человека, враждебен к инструментам)
failed to charge card user=42 amount=19.99 ms=842 err=timeout
Структурированный (ориентирован на инструменты, но все еще читаем)
{"msg":"failed to charge card","user_id":42,"amount":19.99,"duration_ms":842,"error":"timeout"}
В production-среде полезно рассматривать журналы как поток событий, генерируемый процессом, в то время как маршрутизация и хранение находятся вне приложения. Эта ментальная модель подталкивает вас к записи одного события на строку и сохранению событий простыми для передачи и повторной обработки.
Slog в Go как общий фронтенд для логирования
В Go есть классический пакет log с незапамятных времен, но современным сервисам нужны уровни и поля. Пакет log/slog (Go 1.21 и новее) добавляет структурированное логирование в стандартную библиотеку и формализует общую форму для записей логов: время, уровень, сообщение и атрибуты. Для краткого обновления языка и команд рядом с этим руководством см. Шпаргалку по Go.
Ключевые части модели:
Record (Запись)
Запись — это то, что произошло. В терминах slog она содержит время, уровень, сообщение и набор атрибутов. Вы создаете записи через методы, такие как Info и Error, или через Log, когда хотите явно указать уровень.
Attributes (Атрибуты)
Атрибуты — это пары «ключ-значение», которые делают логи доступными для запросов. Если вы логируете одно и то же понятие под тремя разными ключами (user, userId, uid), вы получите три разных набора данных. В согласованных ключах скрывается настоящая ценность.
Handler (Обработчик)
Обработчик — это то, как записи превращаются в байты. Встроенный TextHandler записывает вывод в формате key=value, а JSONHandler — JSON, разделенный строками. Обработчики — это также место, где обычно происходит цензурирование (redaction), переименование ключей и маршрутизация вывода.
Одной из недооцененных функций является то, что slog может располагаться перед существующим кодом. Когда вы устанавливаете логгер slog по умолчанию, функции верхнего уровня slog используют его, и классический пакет log также может быть перенаправлен на него. Это делает возможным постепенный переход.
Groups (Группы)
Группы решают проблему «каждая подсистема использует id». Вы можете сгруппировать набор атрибутов для запроса (request.method, request.path) или изолировать всю подсистему с помощью WithGroup, чтобы ключи не конфликтовали.
Production-готовая настройка slog
Следующая настройка достигает обычных целей.
В примерах используется небольшой пакет logx; чтобы узнать, где такие пакеты обычно находятся в реальном модуле, см. Структура проектов Go: Практики и Паттерны.
- одно JSON-событие на строку
- логи пишутся в stdout для сбора
- стабильные метаданные сервиса прикрепляются один раз
- логирование с учетом контекста для ID запроса и трассировки
- центральное цензурирование (redaction) для чувствительных ключей
package logx
import (
"log/slog"
"os"
)
var level slog.LevelVar // по умолчанию INFO
func New() *slog.Logger {
opts := &slog.HandlerOptions{
Level: &level, // может быть изменен во время выполнения
AddSource: true, // включить файл и строку, если доступно
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Централизованное цензурирование: согласованно и трудно обойти случайно.
switch a.Key {
case "password", "token", "authorization", "api_key":
return slog.String(a.Key, "[redacted]")
}
return a
},
}
h := slog.NewJSONHandler(os.Stdout, opts)
return slog.New(h).With(
"service", os.Getenv("SERVICE_NAME"),
"env", os.Getenv("ENV"),
"version", os.Getenv("VERSION"),
)
}
func SetLevel(l slog.Level) { level.Set(l) }
Небольшая деталь с большими последствиями: встроенный обработчик JSON использует стандартные ключи (time, level, msg, source). Когда ваш бэкенд логов ожидает другой схемы, ReplaceAttr — это клапан сброса давления, который позволяет нормализовать ключи без переписывания мест вызовов.
Схема важнее логгера
Большинство провалов «структурированного логирования» — это провалы схемы.
Основные поля, которые продолжают приносить пользу
Любой бэкенд логов будет хранить временную метку, уровень и сообщение. На практике, полезная схема приложения часто добавляет небольшой набор стабильных полей:
- service, env, version
- component (или subsystem)
- event (устойчивое имя для того, что произошло)
- request_id (когда существует запрос)
- trace_id и span_id (когда существует трассировка)
- error (строка) и error_kind (устойчивая категория)
Обратите внимание на паттерн: эти поля отвечают на операционные вопросы, а не на любопытство разработчика.
Семантические конвенции — это дешевый хак для согласованности
Если вы уже используете OpenTelemetry, его семантические конвенции предоставляют стандартный словарь атрибутов для сигналов телеметрии. Даже если вы не экспортируете логи через OpenTelemetry, заимствование имен атрибутов снижает «налог» на вопрос «как мы назвали это поле в сервисе B».
Высокая кардинальность и почему логи становятся дорогими
Высокая кардинальность означает «слишком много уникальных значений». Это нормально внутри JSON-пейлоада, но становится болезненным, когда бэкенд рассматривает некоторые поля как индексируемые лейблы или ключи потоков. ID пользователей, IP-адреса, случайные токены запросов и полные URL-адреса склонны взрывать комбинации.
Практический результат прост: держите лейблы и ключи индексов скучными (service, environment, region), а поля с высокой кардинальностью держите внутри структурированного пейлоада для фильтрации во время запроса.
Корреляция с ID запросов и трассировками
Корреляция — это точка, где логи перестают быть просто текстом и начинают вести себя как телеметрия.
Request ID как ключ корреляции с наименьшим сопротивлением
Request ID — это самый простой мост между входящим запросом и всем, что происходит из-за него. Он часто работает даже без распределенной трассировки, и он все еще полезен, когда трассировки самплятся. Для полной картины того, как ID запросов и другие метаданные должны храниться и извлекаться из контекста — включая паттерны типизированных ключей и примеры middleware — см. Go context.Context Done Right.
Распространенным паттерном является прикрепление логгера для каждого запроса к контексту:
package logx
import (
"context"
"log/slog"
)
type ctxKey struct{}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && l != nil {
return l
}
return slog.Default()
}
Корреляция трассировок с W3C Trace Context и OpenTelemetry
W3C Trace Context определяет стандартный способ передачи идентификатора трассировки (для HTTP, через traceparent и tracestate). OpenTelemetry строится на этом, так что ID трассировки и ID спана могут быть извлечены из контекста.
Этот пример middleware логирует как request_id, так и идентификаторы трассировки, когда они доступны:
package middleware
import (
"crypto/rand"
"encoding/hex"
"net/http"
"go.opentelemetry.io/otel/trace"
"log/slog"
"example.com/project/logx"
)
func requestID() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func WithRequestLogger(base *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := r.Header.Get("X-Request-Id")
if rid == "" {
rid = requestID()
}
l := base.With(
"request_id", rid,
"method", r.Method,
"path", r.URL.Path,
)
if sc := trace.SpanContextFromContext(r.Context()); sc.IsValid() {
l = l.With(
"trace_id", sc.TraceID().String(),
"span_id", sc.SpanID().String(),
)
}
ctx := logx.WithLogger(r.Context(), l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Как только поля корреляции существуют, строка лога становится индексом для других данных. Разница при живом инциденте не является тонкой.
Превращение структурированных логов в сигналы мониторинга и оповещений
Логи отлично отвечают на вопрос «что произошло». Оповещения обычно касаются «как часто и насколько плохо».
Практический подход — рассматривать определенные события логов как счетчики:
- event=payment_failed
- event=db_timeout
- event=cache_miss
Многие платформы могут выводить метрики на основе логов, подсчитывая соответствующие записи за окно времени. Структурированные логи делают этот подсчет устойчивым, потому что он основан на значении поля, а не на хрупком текстовом совпадении. Когда вы будете готовы визуализировать и исследовать эти сигналы, Установка и использование Grafana на Ubuntu: Полное руководство описывает полную настройку Grafana, которую можно направить на общие бэкенды логов и метрик.
Здесь также начинают играть роль уровни логов. Логи отладки часто ценны, но именно там прячутся затраты и шум. Использование динамического уровня (LevelVar) позволяет системе молчать по умолчанию, одновременно позволяя получать детальную информацию при необходимости.
Заключительные мысли
Структурированное логирование в Go больше не является дискуссией о библиотеках. Интересная часть — это то, насколько ваши записи логов согласованы, коррелируемы и дешевы для хранения.
Когда ваши логи содержат стабильные поля, такие как event, request_id и trace_id, они перестают быть «строками, написанными кем-то» и начинают быть набором данных, с которым можно оперировать.
Примечания
Команда Go представила log/slog в Go 1.21 и подчеркнула, что структурированные логи используют пары «ключ-значение», чтобы их можно было надежно парсить, фильтровать, искать и анализировать, а также отметили мотивацию предоставления общего фреймворка, разделяемого по всей экосистеме.
Документация пакета log/slog определяет модель записи (время, уровень, сообщение, пары «ключ-значение») и встроенные обработчики (TextHandler для key=value и JSONHandler для JSON, разделенного строками), а также документирует интеграцию SetDefault с классическим пакетом log.
Для распределенной корреляции спецификация W3C Trace Context стандартизирует передачу traceparent и tracestate, а OpenTelemetry указывает, что его SpanContext соответствует W3C Trace Context и предоставляет TraceId и SpanId, делая корреляцию логов и трассировок простой, когда спан присутствует.
Что касается стоимости и производительности хранения логов, документация Grafana Loki настоятельно рекомендует ограниченные, статические лейблы и предупреждает о лейблах с высокой кардинальностью, создающих слишком много потоков и огромный индекс, что напрямую связано с решением о том, что становится лейблом, а что остается неиндексированным JSON-полем.