Структурированное логирование в Go с помощью Slog
- среда, 6 марта 2024 г. в 00:00:16
Более 10 лет разработчики на Go жаловались на отсутствие структурированного логирования в ядре Golang. Участники сообщества Golang даже создали несколько собственных пакетов, таких как Logrus, Zap и Zerolog. В 2023 году, команда разработчиков Google Go наконец-то представила Slog — высокопроизводительный пакет для структурированного ведения логов в стандартной библиотеке Go. Мы перевели гайд о возможностях slog.
Пакет Slog берет свое начало в дискуссии на GitHub, открытой Джонатаном Амстердамом. В ней был разработан проект пакета. После доработки он был выпущен в Go v1.21 и сейчас находится по адресу log/slog
.
Далее мы посмотрим на возможности Slog с сопутствующими примерами. Для сравнения производительности с другими фреймворками для протоколирования в Go обратитесь к этому репозиторию GitHub.
Давайте начнем знакомство с пакетом 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"
}
}
Пакет 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"}
Как уже говорилось, и 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 — обеспечивает более гибкое форматирование атрибутов.
До сих пор мы использовали в основном стандартные варианты методов уровня, такие как 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"
}
Для типа 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
позволяет стандартизировать вывод логов, указав, как должны записываться пользовательские типы.
Вот его сигнатура:
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.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 логирования на другой во всём приложении.
После того как вы настроили 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, которая здорово помогает оптимизировать вспомогательные процессы. Вебинар будет доступен в записи.