Более 10 лет разработчики на Go жаловались на отсутствие структурированного логирования в ядре Golang. Участники сообщества Golang даже создали несколько собственных пакетов, таких как Logrus, Zap и Zerolog. В 2023 году, команда разработчиков Google Go наконец-то представила Slog — высокопроизводительный пакет для структурированного ведения логов в стандартной библиотеке Go. Мы перевели гайд о возможностях slog.

Предыстория
Пакет Slog берет свое начало в дискуссии на GitHub, открытой Джонатаном Амстердамом. В ней был разработан проект пакета. После доработки он был выпущен в Go v1.21 и сейчас находится по адресу log/slog.
Далее мы посмотрим на возможности Slog с сопутствующими примерами. Для сравнения производительности с другими фреймворками для протоколирования в Go обратитесь к этому репозиторию GitHub.
Начало работы со Slog
Давайте начнем знакомство с пакетом log/slog с того как он спроектирован. Он предоставляет три основных типа, с которыми вы должны быть знакомы:
Logger: это "фронтэнд" логгирования, который предоставляет методы уровня (Info()иError()) для записи интересующих событий.Record: представление каждого автономного объекта журнала, созданногоLogger.Handler: интерфейс, который, будучи реализованным, определяет форматирование и назначение каждогоRecord. В пакетlog/slogвключены два встроенных обработчика:TextHandlerиJSONHandlerдля вывода данных в форматеkey=valueи JSON соответственно.
Как и большинство библиотек логирования в Go, пакет slog предоставляет стандартный Logger, доступный через функции верхнего уровня. Этот логер выводит почти такой же результат, как и старый метод log.Printf(), за исключением включения уровней журнала:
package main import ( "log" "log/slog" ) func main() { log.Print("Info message") slog.Info("Info message") }
2024/01/03 10:24:22 Info message 2024/01/03 10:24:22 INFO Info message
Это выглядит несколько странно, учитывая, что основная цель Slog — привнести структурирование логов в стандартную библиотеку.
Исправить это достаточно просто, создав собственный экземпляр Logger с помощью метода slog.New(). Он принимает реализацию интерфейса Handler, который определяет, как будут отформатированы журналы и куда они будут записаны.
Вот пример, использующий встроенный тип JSONHandler для вывода JSON-логов в stdout:
func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) logger.Debug("Debug message") logger.Info("Info message") logger.Warn("Warning message") logger.Error("Error message") }
{"time":"2023-03-15T12:59:22.227408691+01:00","level":"INFO","msg":"Info message"} {"time":"2023-03-15T12:59:22.227468972+01:00","level":"WARN","msg":"Warning message"} {"time":"2023-03-15T12:59:22.227472149+01:00","level":"ERROR","msg":"Error message"}
Если вместо этого типа используется TextHandler, каждая запись журнала будет отформатирована в соответствии со стандартом Logfmt:
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
time=2023-03-15T13:00:11.333+01:00 level=INFO msg="Info message" time=2023-03-15T13:00:11.333+01:00 level=WARN msg="Warning message" time=2023-03-15T13:00:11.333+01:00 level=ERROR msg="Error message"
Все экземпляры Logger по умолчанию ведут журнал на уровне INFO, что приводит к подавлению записи DEBUG, но вы можете легко изменить это по своему усмотрению.
Настройка логера по умолчанию
Самый простой способ настроить стандартный Logger — использовать метод slog.SetDefault(), позволяющий заменить стандартный логер на собственный.
func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(logger) slog.Info("Info message") }
Теперь вы увидите, что методы логирования верхнего уровня пакета теперь выдают JSON, как показано ниже:
{"time":"2023-03-15T13:07:39.105777557+01:00","level":"INFO","msg":"Info message"}
Использование метода SetDefault() также изменяет стандартный log.Logger, используемый пакетом log. Такое поведение позволяет существующим приложениям, использующим старый log, плавно перейти к структурированному логированию:
func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(logger) // elsewhere in the application log.Println("Hello from old logger") }
{"time":"2023-03-16T15:20:33.783681176+01:00","level":"INFO","msg":"Hello from old logger"}
Метод slog.NewLogLogger() также доступен для преобразования slog.Logger в log.Logger, когда вам нужно использовать API, требующие последнее (например, http.Server.ErrorLog):
func main() { handler := slog.NewJSONHandler(os.Stdout, nil) logger := slog.NewLogLogger(handler, slog.LevelError) _ = http.Server{ // this API only accepts `log.Logger` ErrorLog: logger, } }
Добавление контекстных атрибутов в записи журнала
Существенным преимуществом структурированного логирования по сравнению с неструктурированными форматами является возможность добавления произвольных атрибутов в виде пар ключ/значение в записи журнала.
Эти атрибуты предоставляют дополнительный контекст о зарегистрированном событии. Это может быть полезно для таких задач, как устранение неполадок, генерация метрик, аудит и других целей.
Вот пример, иллюстрирующий, как это работает в Slog:
logger.Info( "incoming request", "method", "GET", "time_taken_ms", 158, "path", "/hello/world?q=search", "status", 200, "user_agent", "Googlebot/2.1 (+http://www.google.com/bot.html)", )
{ "time":"2023-02-24T11:52:49.554074496+01:00", "level":"INFO", "msg":"incoming request", "method":"GET", "time_taken_ms":158, "path":"/hello/world?q=search", "status":200, "user_agent":"Googlebot/2.1 (+http://www.google.com/bot.html)" }
Все методы уровня (Info(), Debug() и т. д.) принимают сообщение журнала в качестве первого аргумента, а затем неограниченное количество слабо типизированных пар ключ/значение.
Этот API похож на SugaredLogger API в Zap (особенно его методы уровня, заканчивающиеся на w), поскольку в нем приоритет отдается краткости за счет выделения дополнительной памяти.
Но будьте осторожны, поскольку такой подход может привести к неожиданным проблемам. В частности, несбалансированные пары ключ/значение могут привести к проблематичному выводу:
logger.Info( "incoming request", "method", "GET", "time_taken_ms", // the value for this key is missing )
Поскольку ключ time_taken_ms не имеет соответствующего значения, он будет рассматриваться как значение с ключом !BADKEY. Это не очень хорошо, потому что несоответствие свойств может создать плохие записи. Вы можете не знать об этом до тех пор, пока вам не понадобится использовать журналы.
{ "time": "2023-03-15T13:15:29.956566795+01:00", "level": "INFO", "msg": "incoming request", "method": "GET", "!BADKEY": "time_taken_ms" }
Чтобы предотвратить подобные проблемы, можно выполнить команду vet или использовать линтер, который будет автоматически сообщать о таких проблемах.

Другой способ предотвратить подобные ошибки — использовать сильно типизированные контекстные атрибуты, как показано ниже:
logger.Info( "incoming request", slog.String("method", "GET"), slog.Int("time_taken_ms", 158), slog.String("path", "/hello/world?q=search"), slog.Int("status", 200), slog.String( "user_agent", "Googlebot/2.1 (+http://www.google.com/bot.html)", ), )
Такой подход к контекстному логированию гораздо лучше, но он всё равно не является надежным, поскольку ничто не мешает вам смешивать сильно типизированные и слабо типизированные пары ключ/значение подобным образом:
logger.Info( "incoming request", "method", "GET", slog.Int("time_taken_ms", 158), slog.String("path", "/hello/world?q=search"), "status", 200, slog.String( "user_agent", "Googlebot/2.1 (+http://www.google.com/bot.html)", ), )
Чтобы гарантировать безопасность типов при добавлении контекстных атрибутов к записям, вы должны использовать метод LogAttrs() следующим образом:
logger.LogAttrs( context.Background(), slog.LevelInfo, "incoming request", slog.String("method", "GET"), slog.Int("time_taken_ms", 158), slog.String("path", "/hello/world?q=search"), slog.Int("status", 200), slog.String( "user_agent", "Googlebot/2.1 (+http://www.google.com/bot.html)", ), )
Этот метод принимает только тип slog.Attr для пользовательских атрибутов, поэтому невозможно получить несбалансированную пару ключ/значение. Однако его API более запутанный, поскольку в дополнение к сообщению журнала и пользовательским атрибутам в метод всегда нужно передавать контекст (или nil) и уровень журнала.
Группировка контекстных атрибутов
Slog также позволяет группировать несколько атрибутов под одним именем, но вывод зависит от используемого хендлера. Например, при использовании JSONHandler каждая группа вложена в объект JSON:
logger.LogAttrs( context.Background(), slog.LevelInfo, "image uploaded", slog.Int("id", 23123), slog.Group("properties", slog.Int("width", 4000), slog.Int("height", 3000), slog.String("format", "jpeg"), ), )
{ "time":"2023-02-24T12:03:12.175582603+01:00", "level":"INFO", "msg":"image uploaded", "id":23123, "properties":{ "width":4000, "height":3000, "format":"jpeg" } }
При использовании TextHandler каждый ключ в группе будет иметь префикс в виде названия группы, как показано ниже:
time=2023-02-24T12:06:20.249+01:00 level=INFO msg="image uploaded" id=23123 properties.width=4000 properties.height=3000 properties.format=jpeg
Создание и использование дочерних логеров
Иногда может быть полезно включать одни и те же атрибуты во все записи в определённой области. Это будет гарантировать их наличие без повторяющихся операторов записи в лог.
Здесь на помощь приходят дочерние логеры. Они создают новый контекст логирования, который наследуется от родительского логера, и при этом позволяют включать дополнительные поля.
В Slog создание дочерних логеров осуществляется с помощью метода Logger.With(). Он принимает одну или несколько пар ключ/значение и возвращает новый Logger, который включает указанные атрибуты.
Рассмотрим следующий фрагмент кода, который добавляет идентификатор program's process ID и версию Go, использованную для компиляции, к каждой записи в лог и сохраняет их в свойстве program_info.
func main() { handler := slog.NewJSONHandler(os.Stdout, nil) buildInfo, _ := debug.ReadBuildInfo() logger := slog.New(handler) child := logger.With( slog.Group("program_info", slog.Int("pid", os.Getpid()), slog.String("go_version", buildInfo.GoVersion), ), ) . . . }
Если такая конфигурация задана, то все записи, созданные логером child, будут содержать указанные атрибуты в свойстве program_info, пока оно не будет переопределено в точке логирования.
func main() { . . . child.Info("image upload successful", slog.String("image_id", "39ud88")) child.Warn( "storage is 90% full", slog.String("available_space", "900.1 mb"), ) }
{ "time": "2023-02-26T19:26:46.046793623+01:00", "level": "INFO", "msg": "image upload successful", "program_info": { "pid": 229108, "go_version": "go1.20" }, "image_id": "39ud88" } { "time": "2023-02-26T19:26:46.046847902+01:00", "level": "WARN", "msg": "storage is 90% full", "program_info": { "pid": 229108, "go_version": "go1.20" }, "available_space": "900.1 MB" }
Вы также можете использовать метод WithGroup() для создания дочернего логера, который запускает группу. В этом случае все атрибуты, добавленные к логеру (включая те, которые добавлены в точке логирования), будут вложены под именем группы.
handler := slog.NewJSONHandler(os.Stdout, nil) buildInfo, _ := debug.ReadBuildInfo() logger := slog.New(handler).WithGroup("program_info") child := logger.With( slog.Int("pid", os.Getpid()), slog.String("go_version", buildInfo.GoVersion), ) child.Warn( "storage is 90% full", slog.String("available_space", "900.1 MB"), )
{ "time": "2023-05-24T19:00:18.384136084+01:00", "level": "WARN", "msg": "storage is 90% full", "program_info": { "pid": 1971993, "go_version": "go1.20.2", "available_space": "900.1 mb" } }
Настройка уровней Slog
Пакет log/slog предоставляет четыре уровня логирования по умолчанию, каждый из которых связан с целочисленным значением: DEBUG (-4), INFO (0), WARN (4) и ERROR (8).
Разница в четыре единицы между уровнями — это специальное конструктивное решение. Оно принято для того, чтобы предусмотреть схемы логирования с пользовательскими уровнями между уровнями по умолчанию. Например, вы можете создать пользовательский уровень между INFO и WARN со значением 1, 2 или 3.
Ранее мы наблюдали, что все логеры по умолчанию настроены на логирование на уровне INFO, из-за чего события, записанные с меньшей серьёзностью (например, DEBUG), подавляются. Вы можете настроить это поведение через тип HandlerOptions, как показано ниже.
func main() { opts := &slog.HandlerOptions{ Level: slog.LevelDebug, } handler := slog.NewJSONHandler(os.Stdout, opts) logger := slog.New(handler) logger.Debug("Debug message") logger.Info("Info message") logger.Warn("Warning message") logger.Error("Error message") }
{"time":"2023-05-24T19:03:10.70311982+01:00","level":"DEBUG","msg":"Debug message"} {"time":"2023-05-24T19:03:10.703187713+01:00","level":"INFO","msg":"Info message"} {"time":"2023-05-24T19:03:10.703190419+01:00","level":"WARN","msg":"Warning message"} {"time":"2023-05-24T19:03:10.703192892+01:00","level":"ERROR","msg":"Error message"}
Такой подход к установке уровня фиксирует уровень handler на протяжении всего времени его работы. Если вам нужно, чтобы минимальный уровень динамически изменялся, вы должны использовать тип LevelVar, как показано ниже:
func main() { logLevel := &slog.LevelVar{} // INFO opts := &slog.HandlerOptions{ Level: logLevel, } handler := slog.NewJSONHandler(os.Stdout, opts) . . . }
Впоследствии вы можете обновить уровень журнала в любое время, используя следующее:
logLevel.Set(slog.LevelDebug)
Создание пользовательских уровней журнала
Если вам нужны собственные уровни, помимо тех, что Slog предоставляет по умолчанию, вы можете создать их через интерфейс Leveler, сигнатура которого выглядит следующим образом:
type Leveler interface { Level() Level }
Этот интерфейс легко реализовать через тип Level, показанный ниже (поскольку сам Level реализует Leveler):
const ( LevelTrace = slog.Level(-8) LevelFatal = slog.Level(12) )
Определив пользовательские уровни, как указано выше, вы можете использовать их только через метод Log() или LogAttrs():
opts := &slog.HandlerOptions{ Level: LevelTrace, } logger := slog.New(slog.NewJSONHandler(os.Stdout, opts)) ctx := context.Background() logger.Log(ctx, LevelTrace, "Trace message") logger.Log(ctx, LevelFatal, "Fatal level")
{"time":"2023-02-24T09:26:41.666493901+01:00","level":"DEBUG-4","msg":"Trace level"} {"time":"2023-02-24T09:26:41.666602404+01:00","level":"ERROR+4","msg":"Fatal level"}
Обратите внимание, как пользовательские уровни обозначены по умолчанию. Это не то что нужно. Поэтому следует настроить имена уровней через тип HandlerOptions следующим образом:
. . . var LevelNames = map[slog.Leveler]string{ LevelTrace: "TRACE", LevelFatal: "FATAL", } func main() { opts := slog.HandlerOptions{ Level: LevelTrace, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { if a.Key == slog.LevelKey { level := a.Value.Any().(slog.Level) levelLabel, exists := LevelNames[level] if !exists { levelLabel = level.String() } a.Value = slog.StringValue(levelLabel) } return a }, } . . . }
Функция ReplaceAttr() используется для настройки того, как каждая пара ключ/значение в Record будет обрабатываться Handler. Это может помочь изменить имена ключей или каким-либо образом обработать значения.
В приведенном выше примере сопоставлены пользовательские уровни журнала с их соответствующими метками, производящими TRACE и FATAL соответственно.
{"time":"2023-02-24T09:27:51.747625912+01:00","level":"TRACE","msg":"Trace level"} {"time":"2023-02-24T09:27:51.747737319+01:00","level":"FATAL","msg":"Fatal level"}
Настройка обработчиков Slog
Как уже говорилось, и TextHandler, и JSONHandler можно настраивать с помощью типа HandlerOptions. Вы уже видели, как настраивать минимальный уровень и изменять атрибуты перед записью в журнал.
Еще одна настройка, которую можно выполнить с помощью HandlerOptions, добавляет источник журнала, если это необходимо:
opts := &slog.HandlerOptions{ AddSource: true, Level: slog.LevelDebug, }
{ "time": "2024-01-03T11:06:50.971029852+01:00", "level": "DEBUG", "source": { "function": "main.main", "file": "/home/ayo/dev/betterstack/demo/slog/main.go", "line": 17 }, "msg": "Debug message" }
Кроме того, можно легко менять хендлеры в зависимости от среды приложения. Например, вы можете использовать TextHandler для журналов разработки, поскольку его легче читать, а затем переключиться на JSONHandler в продакшене для большей гибкости и совместимости с различными инструментами логирования.
Такое поведение легко реализовать с помощью переменных окружения:
var appEnv = os.Getenv("APP_ENV") func main() { opts := &slog.HandlerOptions{ Level: slog.LevelDebug, } var handler slog.Handler = slog.NewTextHandler(os.Stdout, opts) if appEnv == "production" { handler = slog.NewJSONHandler(os.Stdout, opts) } logger := slog.New(handler) logger.Info("Info message") }
go run main.go
time=2023-02-24T10:36:39.697+01:00 level=INFO msg="Info message"
APP_ENV=production go run main.go
{"time":"2023-02-24T10:35:16.964821548+01:00","level":"INFO","msg":"Info message"}
Создание пользовательских обработчиков
Поскольку Handler — это интерфейс, можно создавать собственные обработчики для различного форматирования журналов или записи их в другие места.
Его сигнатура выглядит следующим образом:
type Handler interface { Enabled(context.Context, Level) bool Handle(context.Context, r Record) error WithAttrs(attrs []Attr) Handler WithGroup(name string) Handler }
Вот что делает каждый из методов:
Enabled()определяет, следует ли обрабатывать или выбрасывать запись журнала в зависимости от ее уровня. Для принятия решения также может использоватьсяcontext.Handle()обрабатывает каждую запись журнала, отправленную обработчику. Она вызывается только в том случае, еслиEnabled()возвращаетtrue.WithAttrs()создает новый обработчик из существующего и добавляет в него указанные атрибуты.WithGroup()создает новый обработчик из существующего и добавляет в него указанное имя группы так, чтобы это имя квалифицировало последующие атрибуты.
Вот пример, использующий пакеты log, json и color для реализации красивого вывода записей журнала:
// NOTE: Not well tested, just an illustration of what's possible package main import ( "context" "encoding/json" "io" "log" "log/slog" "github.com/fatih/color" ) type PrettyHandlerOptions struct { SlogOpts slog.HandlerOptions } type PrettyHandler struct { slog.Handler l *log.Logger } func (h *PrettyHandler) Handle(ctx context.Context, r slog.Record) error { level := r.Level.String() + ":" switch r.Level { case slog.LevelDebug: level = color.MagentaString(level) case slog.LevelInfo: level = color.BlueString(level) case slog.LevelWarn: level = color.YellowString(level) case slog.LevelError: level = color.RedString(level) } fields := make(map[string]interface{}, r.NumAttrs()) r.Attrs(func(a slog.Attr) bool { fields[a.Key] = a.Value.Any() return true }) b, err := json.MarshalIndent(fields, "", " ") if err != nil { return err } timeStr := r.Time.Format("[15:05:05.000]") msg := color.CyanString(r.Message) h.l.Println(timeStr, level, msg, color.WhiteString(string(b))) return nil } func NewPrettyHandler( out io.Writer, opts PrettyHandlerOptions, ) *PrettyHandler { h := &PrettyHandler{ Handler: slog.NewJSONHandler(out, &opts.SlogOpts), l: log.New(out, "", 0), } return h }
Когда вы используете PrettyHandler в своем коде, например, так:
func main() { opts := PrettyHandlerOptions{ SlogOpts: slog.HandlerOptions{ Level: slog.LevelDebug, }, } handler := NewPrettyHandler(os.Stdout, opts) logger := slog.New(handler) logger.Debug( "executing database query", slog.String("query", "SELECT * FROM users"), ) logger.Info("image upload successful", slog.String("image_id", "39ud88")) logger.Warn( "storage is 90% full", slog.String("available_space", "900.1 MB"), ) logger.Error( "An error occurred while processing the request", slog.String("url", "https://example.com"), ) }
При выполнении программы вы увидите следующий раскрашенный вывод:

На GitHub и на этой странице Go Wiki вы можете найти несколько пользовательских хендлеров, созданных сообществом. Среди ярких примеров можно назвать:
tint — записывает тонированные (окрашенные) журналы.
slog-sampling — повышает пропускную способность журнала за счет исключения повторяющихся записей журнала.
slog-multi — реализует такие рабочие процессы, как промежуточное ПО, fanout, маршрутизация, обход отказов, балансировка нагрузки.
slog-formatter — обеспечивает более гибкое форматирование атрибутов.
Использование пакета context в Slog
До сих пор мы использовали в основном стандартные варианты методов уровня, такие как Info(), Debug() и другие, но Slog также предоставляет варианты с учетом контекста, принимающие в качестве первого аргумента значение context.Context. Вот сигнатура для каждого из них:
func (ctx context.Context, msg string, args ...any)
С помощью таких методов вы можете распространять контекстные атрибуты между функциями, сохраняя их в Context, чтобы при нахождении этих значений они добавлялись к любым результирующим записям.
Рассмотрим следующую программу:
package main import ( "context" "log/slog" "os" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) ctx := context.WithValue(context.Background(), "request_id", "req-123") logger.InfoContext(ctx, "image uploaded", slog.String("image_id", "img-998")) }
В переменную ctx добавляется request_id и передается в метод InfoContext. Однако при выполнении программы поле request_id не появляется в журнале:
{ "time": "2024-01-02T11:04:28.590527494+01:00", "level": "INFO", "msg": "image uploaded", "image_id": "img-998" }
Чтобы это заработало, нужно создать пользовательский обработчик и переделать метод Handle, как показано ниже:
type ctxKey string const ( slogFields ctxKey = "slog_fields" ) type ContextHandler struct { slog.Handler } // Handle adds contextual attributes to the Record before calling the underlying // handler func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error { if attrs, ok := ctx.Value(slogFields).([]slog.Attr); ok { for _, v := range attrs { r.AddAttrs(v) } } return h.Handler.Handle(ctx, r) } // AppendCtx adds an slog attribute to the provided context so that it will be // included in any Record created with such context func AppendCtx(parent context.Context, attr slog.Attr) context.Context { if parent == nil { parent = context.Background() } if v, ok := parent.Value(slogFields).([]slog.Attr); ok { v = append(v, attr) return context.WithValue(parent, slogFields, v) } v := []slog.Attr{} v = append(v, attr) return context.WithValue(parent, slogFields, v) }
Структура ContextHandler включает в себя интерфейс slog.Handler и реализует метод Handle. Этот метод извлекает атрибуты Slog, которые хранятся в предоставленном контексте. Если они найдены, то добавляются к Record перед вызовом базового Handler для форматирования и вывода записи.
С другой стороны, функция AppendCtx добавляет атрибуты Slog в context.Context с помощью ключа slogFields, чтобы они были доступны для ContextHandler.
Вот как их использовать:
func main() { h := &ContextHandler{slog.NewJSONHandler(os.Stdout, nil)} logger := slog.New(h) ctx := AppendCtx(context.Background(), slog.String("request_id", "req-123")) logger.InfoContext(ctx, "image uploaded", slog.String("image_id", "img-998")) }
Теперь вы увидите, что request_id добавляется во все записи, созданные с помощью аргумента ctx:
{ "time": "2024-01-02T11:29:15.229984723+01:00", "level": "INFO", "msg": "image uploaded", "image_id": "img-998", "request_id": "req-123" }
Логирование ошибок с помощью Slog
Для типа error не предоставляется хелпер, как в большинстве фреймворков, поэтому вы должны использовать slog.Any(), как это сделано здесь:
err := errors.New("something happened") logger.ErrorContext(ctx, "upload failed", slog.Any("error", err))
{ "time": "2024-01-02T14:13:44.41886393+01:00", "level": "ERROR", "msg": "upload failed", "error": "something happened" }
Чтобы получить и записать в лог трассировку стека ошибок, вы можете использовать такую библиотеку, как xerrors, для создания ошибок с трассировкой стека.
err := xerrors.New("something happened") logger.ErrorContext(ctx, "upload failed", slog.Any("error", err))
Прежде чем вы сможете увидеть трассировку стека в журнале ошибок, вам также нужно извлечь, отформатировать и добавить её в соответствующий Record с помощью функции ReplaceAttr(), продемонстрированной ранее.
Вот пример:
package main import ( "context" "log/slog" "os" "path/filepath" "github.com/mdobak/go-xerrors" ) type stackFrame struct { Func string `json:"func"` Source string `json:"source"` Line int `json:"line"` } func replaceAttr(_ []string, a slog.Attr) slog.Attr { switch a.Value.Kind() { case slog.KindAny: switch v := a.Value.Any().(type) { case error: a.Value = fmtErr(v) } } return a } // marshalStack extracts stack frames from the error func marshalStack(err error) []stackFrame { trace := xerrors.StackTrace(err) if len(trace) == 0 { return nil } frames := trace.Frames() s := make([]stackFrame, len(frames)) for i, v := range frames { f := stackFrame{ Source: filepath.Join( filepath.Base(filepath.Dir(v.File)), filepath.Base(v.File), ), Func: filepath.Base(v.Function), Line: v.Line, } s[i] = f } return s } // fmtErr returns a slog.Value with keys `msg` and `trace`. If the error // does not implement interface { StackTrace() errors.StackTrace }, the `trace` // key is omitted. func fmtErr(err error) slog.Value { var groupValues []slog.Attr groupValues = append(groupValues, slog.String("msg", err.Error())) frames := marshalStack(err) if frames != nil { groupValues = append(groupValues, slog.Any("trace", frames), ) } return slog.GroupValue(groupValues...) } func main() { h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ ReplaceAttr: replaceAttr, }) logger := slog.New(h) ctx := context.Background() err := xerrors.New("something happened") logger.ErrorContext(ctx, "image uploaded", slog.Any("error", err)) }
При этом все ошибки, созданные с помощью xerrors.New(), будут логироваться с хорошо отформатированной трассировкой стека следующим образом:
{ "time": "2024-01-03T07:09:31.013954119+01:00", "level": "ERROR", "msg": "image uploaded", "error": { "msg": "something happened", "trace": [ { "func": "main.main", "source": "slog/main.go", "line": 82 }, { "func": "runtime.main", "source": "runtime/proc.go", "line": 267 }, { "func": "runtime.goexit", "source": "runtime/asm_amd64.s", "line": 1650 } ] } }
Теперь вы сможете легко проследить путь выполнения, приводящий к неожиданным ошибкам в вашем приложении.
Как скрывать поля с чувствительными данными с помощью интерфейса LogValuer
Интерфейс LogValuer позволяет стандартизировать вывод логов, указав, как должны записываться пользовательские типы.
Вот его сигнатура:
type LogValuer interface { LogValue() Value }
Рассмотрим пример использования этого интерфейса. Например, вот тип User, который не реализует интерфейс LogValuer. Обратите внимание, как конфиденциальные детали становятся доступны при записи экземпляра в лог:
// User does not implement `LogValuer` here type User struct { ID string `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Email string `json:"email"` Password string `json:"password"` } func main() { handler := slog.NewJSONHandler(os.Stdout, nil) logger := slog.New(handler) u := &User{ ID: "user-12234", FirstName: "Jan", LastName: "Doe", Email: "jan@example.com", Password: "pass-12334", } logger.Info("info", "user", u) }
{ "time": "2023-02-26T22:11:30.080656774+01:00", "level": "INFO", "msg": "info", "user": { "id": "user-12234", "first_name": "Jan", "last_name": "Doe", "email": "jan@example.com", "password": "pass-12334" } }
Это проблема, потому что тип содержит секретные поля, которые не должны присутствовать в логах (электронные письма и пароли), и это также может сделать ваши логи излишне громоздкими.
Вы можете решить эту проблему, указав, как вы хотели бы, чтобы тип был представлен в логах. Например, вы можете указать, что в лог должно быть записано только поле ID следующим образом:
// implement the `LogValuer` interface on the User struct func (u User) LogValue() slog.Value { return slog.StringValue(u.ID) }
Теперь вы увидите следующий результат:
{ "time": "2023-02-26T22:43:28.184363059+01:00", "level": "INFO", "msg": "info", "user": "user-12234" }
Вы также можете сгруппировать несколько атрибутов следующим образом:
func (u User) LogValue() slog.Value { return slog.GroupValue( slog.String("id", u.ID), slog.String("name", u.FirstName+" "+u.LastName), ) }
{ "time": "2023-03-15T14:44:24.223381036+01:00", "level": "INFO", "msg": "info", "user": { "id": "user-12234", "name": "Jan Doe" } }
Использование сторонних бэкендов для логирования с Slog
Одна из главных целей проектирования Slog — предоставить унифицированный интерфейс для логирования (slog.Logger) для Go-приложений, в то время как бэкенд (slog.Handler) остаётся настраиваемым от программы к программе.
Таким образом, API для логирования остаётся согласованным во всех зависимостях, даже если бэкенды различаются. Это также позволяет избежать связывания реализации логирования с конкретным пакетом, делая тривиальным переключение на другой бэкенд, если требования в вашем проекте меняются.
Вот пример, который использует интерфейс Slog с Zap backend, возможно это лучшее из обоих миров:
go get go.uber.org/zap
go get go.uber.org/zap/exp/zapslog
package main import ( "log/slog" "go.uber.org/zap" "go.uber.org/zap/exp/zapslog" ) func main() { zapL := zap.Must(zap.NewProduction()) defer zapL.Sync() logger := slog.New(zapslog.NewHandler(zapL.Core(), nil)) logger.Info( "incoming request", slog.String("method", "GET"), slog.String("path", "/api/user"), slog.Int("status", 200), ) }
В этом фрагменте кода создаётся новый логер Zap для продакшена, который впоследствии используется как обработчик для пакета Slog через zapslog.NewHandler(). После этого вам нужно только писать логи, используя методы, предоставляемые в slog.Logger. Полученные записи будут обрабатываться в соответствии с предоставленной конфигурацией zapL .
{"level":"info","ts":1697453912.4535635,"msg":"incoming request","method":"GET","path":"/api/user","status":200}
Переключение на другой логер очень простое, так как логирование осуществляется в терминах slog.Logger. Например, вы можете переключиться с Zap на Zerolog следующим образом:
go get github.com/rs/zerolog
go get github.com/samber/slog-zerolog
package main import ( "log/slog" "os" "github.com/rs/zerolog" slogzerolog "github.com/samber/slog-zerolog" ) func main() { zerologL := zerolog.New(os.Stdout).Level(zerolog.InfoLevel) logger := slog.New( slogzerolog.Option{Logger: &zerologL}.NewZerologHandler(), ) logger.Info( "incoming request", slog.String("method", "GET"), slog.String("path", "/api/user"), slog.Int("status", 200), ) }
{"level":"info","time":"2023-10-16T13:22:33+02:00","method":"GET","path":"/api/user","status":200,"message":"incoming request"}
В приведённом фрагменте кода обработчик Zap был заменён на пользовательский обработчик Zerolog. Поскольку логирование не выполняется с помощью пользовательских API каждой из библиотек, процесс миграции занимает всего пару минут по сравнению с ситуацией, когда вам приходится заменять один API логирования на другой во всём приложении.
Best practices для записи и хранения логов в Go:
После того как вы настроили Slog или предпочитаемый вами сторонний фреймворк для логирования, рекомендуем придерживаться следующих лучших практик:
1. Стандартизируйте интерфейсы для логирования. Интерфейс LogValuer позволяет стандартизировать способ логирования различных типов в приложении. Это обеспечит согласованное представление этих типов в логах по всему приложению. Кроме того эта стратегия поможет исключить утечку чувствительных данных из логов приложения.
2. Добавляйте трассировку стека в логи ошибок. С трассировкой будет намного легче определить, где возникла ошибка в кодовой базе и какой программный поток привёл к проблеме.
Slog в настоящее время не предоставляет встроенного способа добавления трассировки стека к ошибкам, но, как мы демонстрировали ранее, эту функциональность можно реализовать с помощью пакетов вроде pkgerrors или go-xerrors с помощью пары вспомогательных функций.
3. Проверяйте согласованность вызовов Slog. Одним из главных недостатков API Slog является то, что он позволяет использовать два разных типа аргументов. Это может привести к несогласованности в кодовой базе. Кроме того, вы обеспечьте согласованнность в именах ключей (snake_case, camelCase и т. д.) или чтобы логирующие вызовы содержали аргумент context.
Линтер, подобный sloglint, может помочь вам применить различные правила для Slog. Вот пример конфигурации, используемой через golangci-lint:
linters-settings: sloglint: # Enforce not mixing key-value pairs and attributes. # Default: true no-mixed-args: false # Enforce using key-value pairs only (overrides no-mixed-args, incompatible with attr-only). # Default: false kv-only: true # Enforce using attributes only (overrides no-mixed-args, incompatible with kv-only). # Default: false attr-only: true # Enforce using methods that accept a context. # Default: false context-only: true # Enforce using static values for log messages. # Default: false static-msg: true # Enforce using constants instead of raw keys. # Default: false no-raw-keys: true # Enforce a single key naming convention. # Values: snake, kebab, camel, pascal # Default: "" key-naming-case: snake # Enforce putting arguments on separate lines. # Default: false args-on-sep-lines: true
4. Делайте централизацию логов, но сначала сохраняйте их в локальные файлы. Обычно лучше отделять задачу записи логов от их отправки в централизованную систему управления логами. Запись логов в локальные файлы сначала обеспечивает резервную копию на случай проблем с системой управления логами или сетью. Это может предотвратить возможную потерю важных данных.
Кроме того, сохранение логов локально перед отправкой служит буфером. Это помогает оптимизировать пакетную передачу и снизить влияние на производительность приложения.
Локальное хранение логов также обеспечивает большую гибкость. Если потребуется перейти на другую систему управления логами, нужно будет только изменить способ отправки, а не весь механизм логирования. Для получения более подробной информации смотрите статьи (на английском) об использовании специализированных программ доставки логов, таких как Vector или Fluentd.
Настроить запись в файлы можно по-разному. Например, Systemd может легко перенаправить стандартные потоки вывода и ошибок приложения в файл. Docker также по умолчанию собирает все данные, отправленные в оба потока, и перенаправляет их в локальные файлы на хост-машине.
5. Делайте выгрузку выборок из логов. Выгрузка выборок из логов (log sampling) — это практика записи только наиболее репрезентативных записей логов вместо всех событий. Этот метод полезен в средах с высокой нагрузкой, когда системы генерируют большие объёмы данных логов, и обработка каждого события может быть дорогостоящей.
package main import ( "fmt" "log/slog" "os" slogmulti "github.com/samber/slog-multi" slogsampling "github.com/samber/slog-sampling" ) func main() { // Will print 20% of entries. option := slogsampling.UniformSamplingOption{ Rate: 0.2, } logger := slog.New( slogmulti. Pipe(option.NewMiddleware()). Handler(slog.NewJSONHandler(os.Stdout, nil)), ) for i := 1; i <= 10; i++ { logger.Info(fmt.Sprintf("a message from the gods: %d", i)) } }
{"time":"2023-10-18T19:14:09.820090798+02:00","level":"INFO","msg":"a message from the gods: 4"} {"time":"2023-10-18T19:14:09.820117844+02:00","level":"INFO","msg":"a message from the gods: 5"}
Сторонние фреймворки, такие как Zerolog и Zap, предоставляют встроенные функции выгрузки выборок из логов. В случае с Slog вам потребуется интегрировать сторонний обработчик, такой как slog-sampling, или разработать собственное решение. Также можно выбрать выгрузку выборок через специализированную программу доставки логов, такую как Vector.
6. Используйте систему управления логами. Когда логи централизованы в системе управления, становится легко искать, анализировать и отслеживать поведение приложения на нескольких серверах и в разных окружениях. Когда все логи в одном месте, значительно ускоряется способность идентифицировать и диагностировать проблемы, так как больше не нужно переключаться между разными серверами, чтобы собрать информацию о сервисе.

Заключение
Я надеюсь, что этот обзор помог вам понять, как работает новый пакет структурированного логирования в Go, и как вы можете начать использовать его в своих проектах. Если вы хотите изучить эту тему подробнее, я рекомендую ознакомиться с полным предложением и документацией к пакету.
Если вы изучаете Golang, приходите в Слёрм на курсы Golang для инженеров и Golang-разработчик. Они помогут вам систематизировать знания и получить опыт.
☝️ Сегодня 5 марта мы проведём бесплатный вебинар «Goro для быстрого старта. Куда уходит время разработчика?» На нём мы рассмотрим функционал библиотеки Goro, которая здорово помогает оптимизировать вспомогательные процессы. Вебинар будет доступен в записи.
