以下の内容はhttps://kaminashi-developer.hatenablog.jp/entry/2025/12/17/080000より取得しました。


Go のエラーにコンテキストを持たせていい感じにロギングする

こんにちは。カミナシでID管理・認証基盤の開発に携わっている小松山です。私の携わっているプロダクト『カミナシ ID管理』では、バックエンドに Go を採用しています。この記事では、Go のエラーハンドリングとエラーロギングの改善事例を紹介します。

はじめに

私たちのチームでは、定期的にシステムのメトリクス・トレース・ログなどを確認し、運用の健全性を確認する「サービスレビュー」という取り組みを行っています。その一環で出力されたエラーログを確認しているのですが、以下のような課題がありました。

  • 同じerr を関数・メソッドから受け取った直後にロギングしてしまっている箇所が多く、リクエスト内で発生したエラーに対して複数回エラーログが出力されている
  • context canceled やクライアントエラーのログも Error レベルで出力されている

上記問題のため、サービスレビューを行うたびに「このエラーログは重複しているから1つだけ出力されるようにしよう」「これはログレベルを Info に落とそう」などのチケットを切っては対応することを繰り返していました。これによって段々と改善はされていったものの、いたちごっこになってキリがないこと、そもそもこのような問題が発生してしまうアプリケーションの作りに問題があると考え、エラーハンドリングの方針を転換することに決めました。

エラーハンドリングの方針を決める

『カミナシ ID管理』の API サーバーの多くのエンドポイントの大まかな処理の流れは以下のとおりです。 - echo のリクエストハンドラが HTTP リクエストの情報をパース - ハンドラでユースケースに渡すための input に変換しユースケースを実行 - ユースケースからリポジトリの実装を呼び出す - ユースケースの実行結果を受けてハンドラに返す - ハンドラがレスポンスを返す

前述のとおり、改善前はハンドラ・ユースケース・リポジトリの各レイヤの if err != nil の直後にエラーログを出力するという方針でロギングをしていました。サンプルコードを以下に示します。

// HTTP リクエストに紐づく echo のハンドラ
func CreateUserHandler(c echo.Context) error {
  ctx := c.Request().Context()

  // リクエストボディを usecase の input に変換
  // パラメータの必須チェックなど
  // ...

  // ユースケースの実行
    output, err := createUserUsecase.Execute(ctx, input)
    if err != nil {
      logger.Error(ctx, "failed to execute usecase", zap.Error(err), zap.Any("input", input)) // エラーレベルでの出力
      return RespondError(c, err) // エラーレスポンスの返却
    }

    return c.JSON(output) // 正常レスポンスの返却
}
// ハンドラから呼び出されるユースケース
func (u *CreateUserUsecase) Execute(ctx context.Context, input CreateUserInput) (*CreateUserOutput, error) {
  // 入力値をモデルに変換し、バリデーション
  userModel := input.ToUserModel()
  if err := userModel.Validate(); err != nil {
      logger.Info(ctx "failed to validate user", zap.Error(err), zap.Any("input", input))
      return nil, apperr.New(err, "failed to validate user", apperr.ErrorCodeBadRequest)
  }

  // ユーザーを作成
  created, err := u.userRepository.Create(ctx, userModel)
  if err != nil {
      logger.Error(ctx, "failed to create user", zap.Error(err), zap.Any("input", input))
      return nil, err
  }

  return &CreateUserOutput{User: created}, nil
}
// ユースケースから呼び出される repository の実装
func (g *userGateway) Create(ctx context.Context, user model.User) error {
    q := "INSERT INTO users (name, user_code, email) VALUES ($1, $2, $3);"
    if _, err := g.db.Exec(q, user.Name, user.UserCode, user.Email); err != nil {
        logger.Error(ctx, "failed to insert user", zap.Error(err), zap.String("user", user))
        return nil, apperr.New(err, "failed to insert user", apperr.ErrorCodeUnexpected)
    }
    return nil
}
// その他の gateway メソッド
// ...

この方法は err を受け取ってからエラーログを出すだけで良いという意味ではシンプルでわかりやすいですが、当然エラーログの量が増えてノイズが多くなります。また、同じ関数から返ってくる err にもエラーレベルにすべきものとそうでないものがあります。例えば、バリデーションエラー類、sql.ErrNotFoundcontext.Canceled などはエラーレベルとして扱わなくて良いことが多いです。毎回呼び出し先から返ってくるエラーの内容を気にしながら開発することは認知負荷を増大させてしまったり、同じような if 文を量産したりすることになってしまいます。 err を受けてすぐにエラーログを出すことのもう 1 つのメリットは、ログ出力時点でエラー発生時のコンテキストが把握できることです。たいていのロガーパッケージにはログを出力したコードが書かれたファイル名や行数をログに含めるオプションがついていますし、周辺の関係がありそうな変数をログのフィールドとして自由に追加することもできます。設計当初はこの点を考慮してエラーログの出力方針を決定したという経緯がありました。

改善方針と実装

冒頭に挙げた課題の解決のため、以下の方針で改善を検討しました。

  • 同一のエラーに対するログは1つに集約させる
  • 発生したエラーのログは漏れなく出力する
  • 改善の前後でログで見られる情報を減らさない
  • 予期せぬエラーのみを Error レベル、それ以外は Info レベルで出力する

記事のタイトルからお察しかと思いますが、エラーログの出力箇所を集約しつつ、エラー自体にコンテキストを付与していく方針を決めました。

エラーログの集約

まず、ログの出力を各エンドポイントの HTTP リクエストハンドラ で行うことを決めました。私たちが Web フレームワークとして使っている echo では middleware や error handler を利用してエラーログの出力やエラーレスポンスの書き込みを行うことが一般的かと思いますが、今回は採用しませんでした。

echo.labstack.com

採用しなかった理由は、私たちの開発する API の特性にあります。私たちのチームが開発する『カミナシ ID管理』の API サーバーは、カミナシサービスにおけるユーザーやテナントを一元管理する API であると同時に OpenID Connect に準拠した IdP としても動作します。IdP としての機能の実装には ory/fosite というパッケージを利用しています。fosite の詳細はここでは触れませんが、OAuth 2.0 / OpenID Connect に必要なエンドポイント群を実装するための仕組みを提供してくれます。

github.com

OAuth 2.0 / OpenID Connect 関連の各エンドポイントでは異なる種類のエラーが発生します。これらを単にハンドラー以降へ流すだけにすると、ログレベルを区別するために middleware 側で fosite のエラーハンドリングを頑張る必要が出てきてしまい、処理を必要以上に複雑にしてしまいます。

エラーにコンテキストをもたせる

各レイヤに散在していたエラーログ出力を集約すると、該当のエラーに関するコンテキストが失われてしまいます。ここで「コンテキスト」と言っているのは具体的には以下のような情報です:

  • エラーが発生した、またはその付近のソースコードの行数
    • エラーログの caller として出力していた情報です。Go のエラーにはスタックトレースの機構がないため、何も考えずに err を上流へ伝播するとどこで発生したエラーかわからなくなります
  • エラーに関係のありそうな変数
    • 構造化ログのカスタムフィールドとして出力していた情報です。例えば DB クエリの実行エラーであれば、該当のクエリや関連するパラメータなどが該当します

これらをエラーに保持させるために元々利用されていたカスタムエラー型に「カスタムエラー生成時のスタックトレースを取る機能」「エラーに key-value ペアを追加できる機能」を実装しました。実装自体は難しいものではないため自作しても良かったのですが、このユースケースにぴったりな samber/oops というパッケージを見つけたのでこれを利用することにしました。作者の samber さんは lo をはじめとした便利な Go パッケージを多数公開しています。

github.com

oops は以下のように使います。

err0 := oops.
    In("repository").
    Tags("database", "sql").
    Wrapf(sql.Exec(query), "could not fetch user")  // Wrapf returns nil when sql.Exec() is nil

エラーを生成したりラップしたりする際にメソッドチェーンでコンテキスト情報を追加できます。User, Tenant などの汎用的なコンテキストを追加するメソッドや、任意の key-value ペアを追加できる With などのメソッドが実装されています。 oops を元々実装していたカスタムエラー型の内部実装に利用しました。実装の一部を紹介します。

func New(originalErr error, msg string, errorCode errCode) *AppError {
    oe, ok := oops.AsOops(originalErr)
    if ok {
        // スタックトレースの起点が変わってしまうので、すでに oops.OopsError が error chain に含まれている場合はそれを使う
        return &AppError{
            OriginalErr: oe,
            ResponseMsg: msg,
            ErrorCode:   errorCode,
        }
    }

    if originalErr == nil {
        originalErr = errors.New(msg)
    }

    return &AppError{
        OriginalErr: oops.Wrap(originalErr),
        ResponseMsg: msg,
        ErrorCode:   errorCode,
    }
}

func (ae *AppError) WithAttr(attrFunc ...func(AppError) AppError) *AppError {
    if ae == nil {
        return nil
    }

    copied := *ae
    for _, f := range attrFunc {
        copied = f(copied)
    }
    return &copied
}

func Attr(key string, value any) func(AppError) AppError {
    return func(ae AppError) AppError {
        ae.OriginalErr = oops.With(key, value).Wrap(ae.OriginalErr)
        return ae
    }
}

func (ae *AppError) WithAttr(attrFunc ...func(AppError) AppError) *AppError {
    if ae == nil {
        return nil
    }

    copied := *ae
    for _, f := range attrFunc {
        copied = f(copied)
    }
    return &copied
}

これを以下のように利用します。各レイヤで出力していたエラーログの出力処理を削除し、返却するエラーにコンテキストを追加して呼び出し元へ err を返します。

// ハンドラから呼び出されるユースケース
func (u *CreateUserUsecase) Execute(ctx context.Context, input CreateUserInput) (*CreateUserOutput, error) {
  // 入力値をモデルに変換し、バリデーション
  userModel := input.ToUserModel()
  if err := userModel.Validate(); err != nil {
      return nil, apperr.New(err, "failed to validate user", apperr.ErrorCodeBadRequest).WithAttr(apperr.Attr("input", input))
  }

  // ユーザーを作成
  created, err := u.userRepository.Create(ctx, userModel)
  if err != nil {
      return nil, apperr.WithAttr(err, apperr.Attr("input", input))
  }

  return &CreateUserOutput{User: created}, nil
}

エラーに追加したコンテキストは後から取り出すことができます。oops で作成したエラーの Error() メソッドで取得できるのはラップされた元のエラーメッセージのみなので、好みに応じてスタックトレースや、追加した key-value ペアを構造化ログで見やすい形に出力する必要があります。私たちは zap を利用しているため、カスタムエラー型に zapcore.ObjectMarshaler interfaceを実装することでエラーを整形しています。

var _ zapcore.ObjectMarshaler = &AppError{}

func (e *AppError) MarshalLogObject(enc zapcore.ObjectEncoder) error {
    oe, ok := oops.AsOops(e.OriginalErr)
    if ok {
        // 追加した key-value ペアを map として展開
        for k, v := range oe.ToMap() {
            switch k {
            // 各キーごとに整形をしたければここに処理を追加
            default:
                enc.AddReflected(k, v)
            }
        }
    }
    return nil
}

出力されるエラーログのサンプルは以下のとおりです。

{
  "level": "info",
  "caller": "/app/controller/response.go:60",
  "message": "client error occurred", // ログのメッセージ
  "timestamp": "2025-12-09T00:16:00+09:00",
  // 整形したエラー情報
  "error": {
    "message": "failed to validate user: invalid email format", // エラーメッセージ
    "time": 1765206445, // エラー発生時刻のタイムスタンプ (oops によって自動的に追加されます)
    "stacktrace": [
      // スタックトレース (oops によって生成されたものを配列に整形しています)
      "/app/infra/gateway/xxx.go:xx",
      "/app/usecase/usecase/yyy.go:yyy",
      "/app/interface/controller/zzz.go:zzz"
    ],
    // WithAttr で追加した key-value ペア
    "contexts": {
      "input": {
        "name": "john doe",
        "user_code": "000",
        "email": "不正なメール@example.com"
      }
    }
  }
}

Claude Code Skills を使って一気に書き換え

方針を決めただけでは課題の解決にはなりません。既存のコードベースに方針を適用して意味のあるものになります。ですが、各レイヤに散在しているエラーログの出力の削除、カスタムエラーへの key-value の追加、カスタムエラー型を使えていない箇所の修正など、コードベース全体にわたる修正が必要になります。影響のあるファイルを grep し 1 ファイルずつ修正を適用していくガッツには自信がありましたが、幸いにも Claude Code Skills という便利なツールがあるのでこれを利用することにしました。 修正内容はある程度パターン化できるため、SKILL.md にコーディングルールを書き出し、Claude Code に実行させました。コードレビューのしやすさの観点から gateway, usecase, controller... と各レイヤごとに修正を指示しました。修正ファイルが多く、意外とすぐにコンテキストがいっぱいになってしまって意図と違うコードを書いてくることがあったため、若干人間による修正が必要になりましたが、全体としてはかなり作業工数を削減できました。

おわりに

エラーログによるノイズの多さの課題から、エラー自体にコンテキストを持たせてエラーロギングを集約した取り組みを紹介しました。ノイズを減らすことでより重要なエラーログに注目しやすくなったことはもちろん、コーディングの際もログの出力重複・漏れを気にする必要がなくなったことで、認知負荷が減らせたこともメリットだったと感じています。Go でアプリケーションを開発していると、エラーハンドリングの方針に悩むことは多々あるかと思いますが、この記事が参考になれば嬉しいです。

[余談] この記事の執筆中に newmo さんが公開するエラーパッケージ「ergo」の紹介記事が話題になっているのを見かけました。newmo さんの記事の中で紹介されている機能は今回の取り組みの方向性とかなり似ていると感じました。エラーの改善を考えて着地したらそうなるよね〜と共感でき、自分の考えの筋が正しそうだったことを認識できたと同時に、ネタが被ってしまうことで記事の公開を躊躇してしまいそうになりましたが、カミナシのバリューである「全開オープン」のマインドで記事を書き切ることにしました。ergo はとても便利そうなパッケージなので、次に同じような課題に直面したときにはぜひ試してみたいと思います。

tech.newmo.me

カミナシではエラーハンドリングが大好きなソフトウェアエンジニアを募集しています。このような取り組みに興味をお持ちの方はぜひご応募ください!




以上の内容はhttps://kaminashi-developer.hatenablog.jp/entry/2025/12/17/080000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14