slog 終極指南

在本文中,我們將探討 Go 中的結構化日誌記錄,並特別關注最近推出的 log/slog[1] 軟件包, 這個軟件包旨在爲 Go 帶來高性能、結構化和分級的日誌記錄標準庫。

該軟件包起源於由 Jonathan Amsterdam 發起的 GitHub 討論 [2], 後來專門建立了一個提案 [3] 細化設計。一旦定稿,它在 Go v1.21 版本中發佈。

在以下各節中,我將全面呈現 slog 的功能, 提供相應的例子。至於它和其它日誌框架的性能比較,請參閱此 GitHub 日誌框架 benchmark[4].

開始使用 Slog

讓我們從探討該包的設計和架構開始。它提供了三種您應該熟悉的主要類型:log/slog

與大多數 Go 日誌庫 [5] 一樣, slog包公開了一個可通過包級別函數訪問的默認 Logger。該 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 的主要目的是爲標準庫帶來結構化日誌記錄。

不過, 通過 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

自定義默認 logger 最直接的方式是利用 slog.SetDefault() 方法, 允許您用自定義的 logger 替換默認的 logger:

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 包使用的默認 log.Logger。這種行爲允許利用舊的 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"}

當您需要利用需要 log.Logger 的 API 時 (如 http.Server.ErrorLog),slog.NewLogLogger() 方法也可用於將slog.Logger 轉換爲 log.Logger

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 類似於 Zap 中的 SugaredLogger API[6](具體是以 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 命令或使用 lint 工具自動報告此類問題:

$ go vet ./...
# github.com/smallnest/study/logging
# [github.com/smallnest/study/logging]
./main.go:6:2: call to slog.Info missing a final value

另一種防止此類錯誤的方法是使用如下所示的強類型上下文屬性 [7]:

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 還允許在單個名稱下對多個屬性進行分組, 但輸出取決於所使用的 Handler。例如, 對於 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

創建和使用子 logger

在特定範圍內的所有記錄中包含相同的屬性, 可以確保它們的存在而無需重複的日誌記錄語句, 這是很有益的。

這就是子 logger 可以派上用場的地方, 它創建了一個新的日誌記錄上下文, 該上下文從其父級繼承而來, 同時允許包含額外的字段。

slog 中, 創建子 logger 是使用 Logger.With() 方法完成的。它接受一個或多個鍵 / 值對, 並返回一個包含指定屬性的新 Logger

考慮以下代碼片段, 它將程序的進程 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),
        ),
    )

    . . .
}

有了這個配置,所有由子日誌記錄器創建的記錄都會包含 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 軟件包默認提供四個日誌級別,每個級別都與一個整數值相關聯:

每個級別之間間隔 4 是經過深思熟慮的設計決策,目的是爲了適應在默認級別之間使用自定義級別的日誌記錄方案。例如,您可以使用 123 的值創建介於 INFOWARN 之間的新日誌級別。

我們之前看到過,默認情況下所有日誌記錄器都配置爲 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"}

這種設置日誌級別的方法會固定處理程序在整個生命週期內的日誌級別。如果您需要動態調整 [8] 最低日誌級別,則必須使用 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() 函數用於自定義 Handler 如何處理 Record 中的每個鍵值對。它可以用來修改鍵名,或者以某種方式處理值。

在給定的示例中,它將自定義日誌級別映射到它們各自的標籤,分別生成 TRACEFATAL:

{"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 處理程序

正如之前提到的,TextHandlerJSONHandler 都可以使用 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
}

以下是每個方法的解釋:

這是一個使用 log、json 和 color[9] 軟件包來實現美化開發環境日誌輸出的示例:

// 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[10] 和這篇 Go Wiki 頁面 [11] 上找到社區創建的幾個自定義處理程序。一些值得關注的例子包括:

使用 context 包

到目前爲止,我們主要使用的是諸如 Info()Debug() 等標準級別的函數,但 slog 還提供了支持 context 的變體,這些變體將 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 方法。該方法的作用是提取提供者上下文 (context) 中存儲的 Slog 屬性。如果找到這些屬性,它們將被添加到日誌記錄 (Record) 中。然後,底層的處理程序 (Handler) 會被調用,負責格式化並輸出這條日誌記錄。

另一方面,AppendCtx 函數用於向 context.Context 中添加 Slog 屬性。它使用 slogFields 這個鍵作爲標識符,使得 ContextHandler 能夠訪問這些屬性。

下面將介紹如何同時使用這兩個部分來讓 request_id 信息出現在日誌中:

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"))
}

現在您將觀察到,使用 ctx 參數創建的任何日誌記錄中都包含了 request_id 信息。

{
  "time": "2024-01-02T11:29:15.229984723+01:00",
  "level": "INFO",
  "msg": "image uploaded",
  "image_id": "img-998",
  "request_id": "req-123"
}

slog 錯誤日誌記錄

與大多數框架不同,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[16] 之類的庫來創建帶有堆棧跟蹤的錯誤對象:

err := xerrors.New("something happened")

logger.ErrorContext(ctx, "upload failed", slog.Any("error", err))

爲了在錯誤日誌中看到堆棧跟蹤信息,您還需要像之前提到的 ReplaceAttr() 函數一樣,提取、格式化並將其添加到對應的 Record 中。

下面是一個例子:

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)
}

You will now observe the following output:

{
  "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 設計的主要目標之一是在 Go 應用程序中提供統一的日誌記錄前端(slog.Logger),同時後端(slog.Handler)可以根據程序進行定製。

這樣一來,即使後端不同,所有依賴項使用的日誌記錄 API 都是一致的。同時,通過使切換不同的後端變得簡單,避免了將日誌記錄實現與特定包耦合在一起。

以下示例展示了將 Slog 前端與 Zap 後端結合使用,可以實現兩者的優勢:

$ 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 生產環境日誌記錄器,然後通過 zapslog.NewHandler() 將其用作 Slog 包的處理程序。配置完成後,您只需使用 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 切換到另一個,那將需要更長時間。

Go 日誌編寫和存儲的最佳實踐

一旦你配置了 Slog 或你偏好的第三方 Go 日誌記錄框架,爲確保你能夠從應用程序日誌中獲得最大價值,有必要採用以下最佳實踐:

1、標準化你的日誌接口實現 LogValuer 接口可以確保你的應用程序中的各種類型在日誌記錄時的表現一致。這也是確保敏感字段不被包含在應用程序日誌中的有效策略,正如我們在本文前面所探討的。

2、在錯誤日誌中添加堆棧跟蹤爲了提高在生產環境中調試意外問題的能力,你應該在錯誤日誌中添加堆棧跟蹤。這樣,你就能夠更容易地定位錯誤在代碼庫中的起源以及導致問題的程序流程。

目前,Slog 並沒有提供將堆棧跟蹤添加到錯誤中的內置方式,但正如我們之前所展示的,可以使用像 pkgerrors[17] 或 go-xerrors[18] 這樣的包,配合一些輔助函數來實現這一功能。

3、Lint Slog 語句以確保一致性 Slog API 的一個主要缺點是它允許兩種不同類型的參數,這可能導致代碼庫中的不一致性。除此之外,你還希望強制執行一致的鍵名約定(如 snake_case、camelCase 等),或者要求日誌調用始終包括上下文參數。

像 sloglint[19] 這樣的 linter 可以幫助你基於你偏好的代碼風格來強制執行 Slog 的各種規則。以下是通過 golangci-lint[20] 使用時的一個示例配置:

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[21] 或 Fluentd[22] 等專用日誌發送程序的更多詳細信息,請參閱我們的文章 [23]。

將日誌記錄到文件並不一定要求你配置所選的框架直接寫入文件,因爲 Systemd[24] 可以輕鬆地將應用程序的標準輸出和錯誤流重定向到文件。Docker[25] 也默認收集發送到這兩個流的所有數據,並將它們路由到主機上的本地文件。

** 5、對日誌進行採樣 **

日誌採樣是一種只記錄日誌條目中具有代表性的子集,而不是記錄每個日誌事件的做法。在高流量環境中,系統會產生大量的日誌數據,而處理每個條目可能成本高昂,因爲集中式日誌解決方案通常根據數據攝入率或存儲量進行收費,因此這種技術非常有用:

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[26],或開發一個自定義解決方案。你還可以通過專用的日誌發送程序(如 Vector)來選擇對日誌進行採樣。

6、使用日誌管理服務

將日誌集中在一個日誌管理系統中,可以方便地跨多個服務器和環境搜索、分析和監控應用程序的行爲。將所有日誌集中在一個地方,可以顯著提高你識別和診斷問題的能力,因爲你不再需要在不同的服務器之間跳轉來收集有關你的服務的信息。

雖然市面上有很多日誌管理解決方案,但 Better Stack 提供了一種簡單的方法,可以在幾分鐘內設置集中式日誌管理,其內置了實時追蹤、報警、儀表板、正常運行時間監控和事件管理功能,並通過現代化和直觀的用戶界面進行展示。在這裏,你可以通過完全免費的計劃來試用它。

總結

我希望這篇文章能讓你對 Go 語言中新的結構化日誌包有所瞭解,以及如何在你的項目中使用它。如果你想進一步探索這個話題,我建議你查看完整的提案 [27] 和包文檔 [28]。

感謝閱讀,祝你在日誌記錄方面一切順利!

補充信息

q 其他一些和 slog 有關的資源。

通用處理器

格式化

提供日誌格式化的庫。

日誌增強

用於豐富日誌記錄的處理程序。

日誌轉發

一些廠家提供了將 slog 轉發到它們平臺上的功能,比如 datadog、sentry 等, 這裏就不贅述了,因爲這些都是第三方的服務,在國內也不太合適使用。其他一些轉發的庫:

和其它日誌框架集成

其他日誌庫的適配器。

集成到 web 框架

其他

原文: Logging in Go with Slog: The Ultimate Guide: https://betterstack.com/community/guides/logging/logging-in-go/ by Ayooluwa Isaiah.

參考資料

[1]

log/slog: https://pkg.go.dev/log/slog

[2]

GitHub 討論: https://github.com/golang/go/discussions/54763

[3]

提案: https://github.com/golang/go/issues/56345

[4]

GitHub 日誌框架 benchmark: https://github.com/betterstack-community/go-logging-benchmarks

[5]

Go 日誌庫: https://betterstack.com/community/guides/logging/best-golang-logging-libraries/

[6]

SugaredLogger API: https://betterstack.com/community/guides/logging/go/zap#examining-zap-s-logging-api

[7]

強類型上下文屬性: https://pkg.go.dev/log/slog#Attr

[8]

動態調整: https://betterstack.com/community/guides/logging/change-log-levels-dynamically/

[9]

color: https://github.com/fatih/color

[10]

GitHub: https://github.com/search?q=slog-+language%3AGo&type=repositories&l=Go

[11]

Go Wiki 頁面: https://tip.golang.org/wiki/Resources-for-slog

[12]

tint: https://github.com/lmittmann/tint

[13]

slog-sampling: https://github.com/samber/slog-sampling

[14]

slog-multi: https://github.com/samber/slog-multi

[15]

slog-formatter: https://github.com/samber/slog-formatter

[16]

xerrors: https://github.com/MDobak/go-xerrors

[17]

pkgerrors: https://github.com/pkg/errors

[18]

go-xerrors: https://github.com/MDobak/go-xerrors

[19]

sloglint: https://github.com/go-simpler/sloglint/releases

[20]

golangci-lint: https://freshman.tech/linting-golang/

[21]

Vector: https://betterstack.com/community/guides/logging/vector-explained/

[22]

Fluentd: https://betterstack.com/community/guides/logging/fluentd-explained/

[23]

我們的文章: https://betterstack.com/community/guides/logging/log-shippers-explained/

[24]

Systemd: https://betterstack.com/community/guides/logging/how-to-control-systemd-with-systemctl/

[25]

Docker: https://betterstack.com/community/guides/logging/how-to-start-logging-with-docker/

[26]

slog-sampling: https://github.com/samber/slog-sampling

[27]

完整的提案: https://go.googlesource.com/proposal/+/master/design/56345-structured-logging.md

[28]

包文檔: https://pkg.go.dev/log/slog

[29]

slog-multi: https://github.com/samber/slog-multi

[30]

slog-sampling: https://github.com/samber/slog-sampling

[31]

slog-shim: https://github.com/sagikazarmark/slog-shim

[32]

sloggen: https://github.com/go-simpler/sloggen

[33]

sloglint: https://github.com/go-simpler/sloglint

[34]

console-slog: https://github.com/phsym/console-slog

[35]

devslog: https://github.com/golang-cz/devslog

[36]

slog-formatter: https://github.com/samber/slog-formatter

[37]

slogor: https://gitlab.com/greyxor/slogor

[38]

slogpfx: https://github.com/dpotapov/slogpfx

[39]

tint: https://github.com/lmittmann/tint

[40]

zlog: https://github.com/jeffry-luqman/zlog

[41]

masq: https://github.com/m-mizutani/masq

[42]

otelslog: https://github.com/go-slog/otelslog

[43]

slog-context: https://github.com/PumpkinSeed/slog-context

[44]

slog-kafka: https://github.com/samber/slog-kafka

[45]

slog-syslog: https://github.com/samber/slog-syslog

[46]

slog-webhook: https://github.com/samber/slog-webhook

[47]

slog-channel: https://github.com/samber/slog-channel

[48]

go-hclog-slog: https://github.com/evanphx/go-hclog-slog

[49]

slog-logrus: https://github.com/samber/slog-logrus

[50]

slog-zap: https://github.com/samber/slog-zap

[51]

slog-zerolog: https://github.com/samber/slog-zerolog

[52]

zaphandler: https://github.com/chanchal1987/zaphandler

[53]

ginslog: https://github.com/FabienMht/ginslog

[54]

slog-chi: https://github.com/samber/slog-chi

[55]

slog-echo: https://github.com/samber/slog-echo

[56]

slog-fiber: https://github.com/samber/slog-fiber

[57]

slog-gin: https://github.com/samber/slog-gin

[58]

discard logger: https://go-review.googlesource.com/c/go/+/548335/5/src/log/slog/example_discard_test.go

[59]

slogtest: https://pkg.go.dev/testing/slogtest@go1.22.1

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/LToRDGPAYIsmR_WBtHo27A