Структурированное логирование в Go с использованием slog для наблюдаемости и оповещений

Запросимые JSON-логи, связанные с трассировками.

Содержимое страницы

Логи — это интерфейс отладки, который всё ещё можно использовать, когда система горит. Проблема в том, что обычные текстовые логи со временем становятся неэффективными: как только вам понадобится фильтрация, агрегация и оповещения, вы начинаете разбирать предложения по словам.

рабочее место с ноутбуком и маскотами Go для улучшения логирования

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

В продакшене полезно воспринимать логи как поток событий, генерируемый процессом, в то время как маршрутизация и хранение находятся вне приложения. Эта ментальная модель подталкивает к написанию одного события на строку и к тому, чтобы события было легко отправлять и перерабатывать.

Slog в Go как общий интерфейс для логирования

В Go классическая пакетная библиотека log существует уже давно, но современным сервисам нужны уровни и поля. Пакет log/slog (Go 1.21 и новее) приносит структурированное логирование в стандартную библиотеку и формализует общую структуру записей логов: время, уровень, сообщение и атрибуты. Для краткого освежения знаний о языке и командах в дополнение к этому руководству см. Шпаргалку по Go.

Ключевые элементы модели:

Запись (Record)

Запись — это то, что произошло. В терминах slog она содержит время, уровень, сообщение и набор атрибутов. Вы создаёте записи с помощью методов, таких как Info и Error, или через Log, когда хотите явно указать уровень.

Атрибуты

Атрибуты — это пары «ключ-значение», которые делают логи пригодными для запросов. Если вы логгируете одно и то же понятие под тремя разными ключами (user, userId, uid), вы получите три разных набора данных. Последовательные ключи — это место, где скрывается реальная ценность.

Обработчик (Handler)

Обработчик — это то, как записи превращаются в байты. Встроенный TextHandler выводит данные в формате key=value, а JSONHandler — в формате JSON с разделением по строкам. Именно здесь обычно происходит удаление чувствительных данных, переименование ключей и маршрутизация вывода.

Одна из недооценённых функций заключается в том, что slog может работать поверх существующего кода. Когда вы устанавливаете логгер slog по умолчанию, функции slog верхнего уровня используют его, и классический пакет log также может быть перенаправлен в него. Это делает постепенную миграцию возможной.

Группы

Группы решают проблему «каждый подсистема использует id». Вы можете сгруппировать набор атрибутов для запроса (request.method, request.path) или именовать всю подсистему с помощью WithGroup, чтобы ключи не конфликтовали.

Настройка slog для продакшена

Следующая настройка достигает обычных целей. Примеры используют небольшой пакет logx; чтобы узнать, где обычно располагаются такие пакеты в реальном модуле, см. Структура проекта Go: практики и паттерны.

  • одно событие JSON на строку
  • логи пишутся в stdout для сбора
  • стабильные метаданные сервиса прикрепляются один раз
  • контекстное логирование для ID запроса и трассировки
  • централизованное удаление чувствительных ключей
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 запроса и трассировками

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

ID запроса как ключ корреляции с наименьшим трением

ID запроса — это самый простой мост между входящим запросом и всем, что происходит из-за него. Он работает даже без распределённой трассировки и остаётся полезным, когда трассировки выборочны.

Распространённый паттерн — прикрепление логгера к контексту для каждого запроса:

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.