0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

#173 Goで構築する構造化ログの実装例

Posted at

はじめに

アプリケーション開発においてログは欠かせない存在です。エラーの調査やシステムの挙動確認、運用監視など、開発から本番運用まであらゆる場面で活用されます。


しかし、単なるテキストの羅列では情報を探しにくく、分析ツールや監視基盤と連携する際に扱いづらいことも少なくありません。
そこで注目されるのが「構造化ログ」です。ログをJSONなどのフォーマットで一貫して記録することで、機械的に扱いやすくなり、ログ検索や可視化ツールとの相性も向上します。


本記事では、Go言語を使って構造化ログを出力する方法を具体的なコード例とともに紹介します。これから構造化ログを組み込むという際に役立つヒントを得られる内容を目指します。

logger - interfaceの作成

ログの利用側が実装に依存しないように、まずは薄いインターフェースを application/interfaces/logger.go に定義します。


※ 私のアプリケーションでは、全メソッドで context.Context を受け取るという前提で作成しています。

logger.go
// application/interfaces/logger.go
package interfaces

import "context"

// Logger は、ロガーのインターフェースを定義します。
type Logger interface {
        Info(ctx context.Context, msg string, args ...any)
        Error(ctx context.Context, err error, msg string, args ...any)
        Debug(ctx context.Context, msg string, args ...any)
        Warn(ctx context.Context, msg string, args ...any)
}

以降のセクションで、以上の interface を満たした slog を用いた実装を進めます。

slog - handlerの作成

slog のハンドラーを拡張し、context からリクエスト単位の属性(例: request_id)を自動で拾ってログに差し込みます。


request_id を拾ってログに差し込む処理は Go言語のloggerをDefault1つで済ませる方法:slog Handlerがcontextの中身を見てよしなにするパターン こちらの記事を参考にさせていただきました。

struct_handler.go
// infrastructure/logger/struct_handler.go
package logger

import (
        "context"
        "log/slog"
        "os"

        "github.com/example/example-api/infrastructure/constant"
)

// LogAttrKey は、ログ属性に追加するためのコンテキストキーの型です。
type LogAttrKey string

const (
        RequestID LogAttrKey = "request_id"
)

func (k LogAttrKey) String() string { return string(k) }

// キーの配列を定数として定義
var contextKeys = []LogAttrKey{
        RequestID,
}

// StructHandler は、コンテキストから値を取得してログ属性に追加するハンドラーです。
type StructHandler struct {
        slog.Handler
}

// Handle は、レコードをハンドルします。
// コンテキストからキーを取得して、レコードに追加します。
// キーは定数で定義されているものを使用します。
func (h *StructHandler) Handle(ctx context.Context, r slog.Record) error {
        for _, key := range contextKeys {
                if v := ctx.Value(key); v != nil {
                        r.AddAttrs(slog.Attr{
                                Key:   key.String(),
                                Value: slog.AnyValue(v),
                        })
                }
        }

        return h.Handler.Handle(ctx, r)
}

// NewStructHandler は、新しいStructHandlerを作成します。
// env が constant.EnvLocal.String() の場合は、JSONIndenterを使用してJSONHandlerを作成します。
// env が constant.EnvTest.String() の場合は、DiscardHandlerを作成します。
// それ以外の場合は、通常のJSONHandlerを作成します。
func NewStructHandler(env string) *StructHandler {
        switch env {
        case "local":
                i := NewJSONIndenter(os.Stdout)
                options := &slog.HandlerOptions{
                        Level: slog.LevelDebug,
                }
                return &StructHandler{
                        Handler: slog.NewJSONHandler(i, options),
                }
        case "test":
                return &StructHandler{
                        Handler: slog.DiscardHandler,
                }
        default:
                options := &slog.HandlerOptions{
                        Level: slog.LevelInfo,
                }
                return &StructHandler{
                        Handler: slog.NewJSONHandler(os.Stdout, options),
                }
        }
}

NewStructHandler では環境変数の実行環境に応じてHandlerのOptionを制御しています。


local(ローカル)の場合:
→ 後記するIndenterの設定により、インデント整形した状態かつDebugレベルのログを出力


test(テスト)の場合:
slog.DiscardHandler によってログを出力しないように無効化


それ以外(本番や検証環境を想定):
→ インデント整形をせず、Infoレベルのログを出力

Indenter

ローカルなどの開発環境では、ある程度人間がぱっと見で認識しやすい状態のログが出力されることが重要です。


認識のし易さに関するアプローチは色々と考えられますが、私のアプリケーションではシンプルにインデントした状態のJSONをログとして出力するようにしています。

出力例
{
  "time": "2025-09-17T21:23:03.455124887+09:00",
  "level": "INFO",
  "msg": "start request",
  "endpoint": "/",
  "method": "GET",
  "params": {},
  "request_id": "2a4a9725-955c-4213-bc7e-1ead820f0840"
}

以下のように実装し、先程の slog.NewJSONHandler を作成する際に渡してあげることで自動でインデント整形されたログが出力されます。

json_indenter.go
// infrastructure/logger/struct_handler.go
package logger

import (
        "bytes"
        "encoding/json"
        "io"
)

// JSONIndenter は、JSONをインデントして書き込むためのインデンターです。
type JSONIndenter struct {
        b *bytes.Buffer
        w io.Writer
}

// NewJSONIndenter は、新しいインデンターを作成します。
// バッファは、4096バイトのサイズで作成します。
func NewJSONIndenter(w io.Writer) *JSONIndenter {
        return &JSONIndenter{
                b: bytes.NewBuffer(make([]byte, 0, 4096)),
                w: w,
        }
}

// Write は、JSONをインデントして書き込みます。
// インデントは、スペース2つで行います。
func (i *JSONIndenter) Write(p []byte) (int, error) {
        i.b.Reset()
        if err := json.Indent(i.b, p, "", "  "); err != nil {
                return 0, err
        }

        n, err := i.b.WriteTo(i.w)
        return int(n), err
}

slog - interfaceの実装

ここまで基盤が作成できたら、とうとう序盤で定義したinterfaceの実装に入ります。
今更ですが、私のアプリケーションでの構造化ログに関する実装はslogを使用しています。
以下が定義したinterfaceを満たした実装です。

struct_logger.go
// Package logger は、ロガーを管理するためのパッケージです。
package logger

import (
        "context"
        "log/slog"

        "github.com/cockroachdb/errors"
        "github.com/example/example-api/application/interfaces"
)

// StructLogger は、ロガーを表す構造体です。
type StructLogger struct{}

// Info は、情報をログに出力します。
func (l *StructLogger) Info(ctx context.Context, msg string, args ...any) {
        slog.InfoContext(ctx, msg, args...)
}

// Error は、エラーをログに出力します。
func (l *StructLogger) Error(ctx context.Context, err error, msg string, args ...any) {
        // エラーがnilの場合はエラーをフォールバック
        if err == nil {
                slog.ErrorContext(ctx, "Error method called with nil error")
                return
        }

        // スタックトレースの取得
        errWithStack := errors.WithStack(err)
        stackTrace := errors.GetReportableStackTrace(errWithStack)

        logArgs := make([]any, 0, len(args)+4)
        logArgs = append(logArgs, "error", err.Error())
        logArgs = append(logArgs, "stack", stackTrace)
        logArgs = append(logArgs, args...)

        slog.ErrorContext(ctx, msg, logArgs...)
}

// Warn は、警告をログに出力します。
func (l *StructLogger) Warn(ctx context.Context, msg string, args ...any) {
        slog.WarnContext(ctx, msg, args...)
}

// Debug は、デバッグ情報をログに出力します。
func (l *StructLogger) Debug(ctx context.Context, msg string, args ...any) {
        slog.DebugContext(ctx, msg, args...)
}

// NewStructLogger は、新しいロガーを作成します。
func NewStructLogger() interfaces.Logger {
        return &StructLogger{}
}

Error に関する実装以外は非常に薄いラッパーになっているので解説は不要かと思います。
Error ではスタックトレースを表示するために cockroachdb/errors を使ってスタックトレースを出力するようにしています。

// Error は、エラーをログに出力します。
func (l *StructLogger) Error(ctx context.Context, err error, msg string, args ...any) {
        // エラーがnilの場合はエラーをフォールバック
        if err == nil {
                slog.ErrorContext(ctx, "Error method called with nil error")
                return
        }

        // スタックトレースの取得
        errWithStack := errors.WithStack(err)
        stackTrace := errors.GetReportableStackTrace(errWithStack)

        logArgs := make([]any, 0, len(args)+4)
        logArgs = append(logArgs, "error", err.Error())
        logArgs = append(logArgs, "stack", stackTrace)
        logArgs = append(logArgs, args...)

        slog.ErrorContext(ctx, msg, logArgs...)
}

渡ってくる errnil であることは実装ミス以外では起こりえませんが、渡ってきた場合にはエラーログをフォールバックするようにしています。


以降の実装ではスタックトレースを取得し、 logArgs として格納し出力するようにしています。

context - LoggerContextの実装

私のアプリケーションでは基本的にログ出力は adapter 層の Controller のみで行うこととしています。
ただ、一部例外的に UseCase で出力したいなどのケースも考えられるので、そういったケースにも対応できるように context へのLogger付与、取り出しができるような関数を作成します。

logger.go
// application/appcontext/logger.go
package appcontext

import (
        "context"

        "github.com/example/example-api/application/interfaces"
)

type LoggerKey struct{}

// FromLoggerContext は、コンテキストから Logger を取得するヘルパー関数です。
// コンテキストに Logger が存在しない場合は nil を返します。
// 呼び出し元は、nil チェックを行うことを推奨します。
func FromLoggerContext(ctx context.Context) interfaces.Logger {
        if logger, ok := ctx.Value(LoggerKey{}).(interfaces.Logger); ok {
                return logger
        }
        return nil
}

// WithLoggerContext は、コンテキストに Logger を追加するヘルパー関数です。
// コンテキストに Logger を追加します。
func WithLoggerContext(ctx context.Context, logger interfaces.Logger) context.Context {
        return context.WithValue(ctx, LoggerKey{}, logger)
}

application 層にこのような実装があるのは非常に悩ましい部分ではありますが、イレギュラーなケースを考慮するとある程度しょうがない部分であると妥協しています。

middleware - AccessLoggerの作成

それでは最後にここまで実装した構造化ログを使って、APIへのリクエストが行われた際のログ一式を実装したmiddlewareを作成して終わります。
※ 今回、middleware は gin を前提として実装していますが、処理自体はどのFWでも流用できるかと思います。

access_logger.go

package middleware

import (
        "context"
        "fmt"
        "time"

        "github.com/gin-gonic/gin"
        "github.com/gofrs/uuid/v5"
        "github.com/example/example-api/application/appcontext"
        "github.com/example/example-api/application/interfaces"
        "github.com/example/example-api/infrastructure/logger"
)

// AccessLogger は、アクセスログを出力するミドルウェアです。
func AccessLogger(structLogger interfaces.Logger) gin.HandlerFunc {
        return func(c *gin.Context) {
                startTime := time.Now()

                requestID := c.GetHeader("X-Request-ID")
                if requestID == "" {
                        if uuid, err := uuid.NewV4(); err == nil {
                                requestID = uuid.String()
                        } else {
                                requestID = fmt.Sprintf("req_%d", time.Now().UnixNano())
                        }
                }

                ctx := context.WithValue(c.Request.Context(), logger.RequestID, requestID)
                ctx = appcontext.WithLoggerContext(ctx, structLogger)
                c.Request = c.Request.WithContext(ctx)

                structLogger.Info(
                        ctx,
                        "start request",
                        "endpoint",
                        c.Request.URL.Path,
                        "method",
                        c.Request.Method,
                        "params",
                        c.Request.URL.Query(),
                )

                c.Next()

                structLogger.Info(
                        ctx,
                        "end request",
                        "status",
                        c.Writer.Status(),
                        "duration_ms",
                        time.Since(startTime).Milliseconds(),
                )
        }
}

このように実装することで、 logger.Info を使った出力で自動的に request_id が伝播され出力されます。
また、今回はAPIアクセス時のログ出力middlewareということで、リクエスト時には endpoint path / http method / url params を、レスポンスでは status code / duration(処理時間) を表示するようにしています。

終わりに

いかがでしたでしょうか、今回アプリケーションでこのような構造化ログを導入することで以下のような恩恵を得られるようになりました。

  • リクエスト単位の属性伝播request_id)により、開始〜終了の関連ログを簡単に追跡可能。
  • context ファーストな設計で、どの層でも同じ属性でログ出力。
  • 環境別の出力ポリシー(ローカルでは整形、テストでは破棄、本番ではJSON)で運用と開発体験を両立。
  • エラーはスタック付きで標準化し、調査コストを削減。

本記事がこれからログ実装を構築する上でのヒントになれれば幸いです。
ここまで読んでいただきありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?