Go言語のシステム開発で迷わない!エラーハンドリングと連携するロガーのベストプラクティス

Go言語のエラーハンドリングと構造化ロギングを統合した運用設計の概念図 バックエンド

Go言語でシステム開発を進める際、エラーハンドリングとロギングは常にセットで議論されるべきテーマです。
しかし、多くのプロジェクトで「エラーをログに出力するだけ」になっていませんか? それでは運用時に真に必要な情報が埋もれ、障害対応の遅延や誤った原因推定を招きます。

本記事では、Goのエラーハンドリングの哲学である「エラーは値である」という原則を軸に、ロガーとどう連携させるべきかを構造的に解説します。
目指すのは、開発時のデバッグ容易性と本番運用時の可観測性を同時に満たす実践的な設計です。

まず、エラーハンドリングで避けるべきアンチパターンを整理しましょう。

  • errors.Newやfmt.Errorfで生成した生のエラーをそのままログ出力し、スタックトレースや発生箇所が特定できない
  • エラーをラップする際に、ログ出力時点でメッセージを整形しすぎて、原本のエラー型や構造が失われる
  • ロガーの出力レベルをdebug固定で開発し、本番ではinfo以上に変更したら、肝心のエラーコンテキストが出力されなくなった

これらはすべて、エラーとログを別物として扱う発想が原因です。
では、どう統合するか。
鍵となるのは構造化ロギングとエラーラッピングの一貫したルールです。

例えば、slogパッケージを使い、エラー発生時に以下のフィールドを必ず含めることをチームで合意します。

  • error_id(リクエスト単位で一意なID)
  • operation(どのレイヤーで発生したか:repository, usecase, handler)
  • stack(必要に応じて短縮スタックトレース)
  • severity(エラーの重要度:fatal, error, warn)

そして、エラー自体にはfmt.Errorfの%wで原因をラップし、上位レイヤーではラップされたエラーからerrors.Asやerrors.Isで型や特定エラーを判定する。
この判定結果に応じて、ログレベルや出力する付加情報を動的に切り替える設計にします。

では、実際のコード例で見てみましょう。

// エラーにオペレーションコンテキストを付与するヘルパー
func WrapOp(err error, op string) error {
    if err == nil {
        return nil
    }
    return fmt.Errorf("%s: %w", op, err)
}

// ハンドラ層でのログ出力例(slog利用)
if err := usecase.Execute(); err != nil {
    slog.LogAttrs(ctx, slog.LevelError, "usecase failed",
        slog.String("operation", "Execute"),
        slog.Any("error", err),
        slog.String("trace_id", traceID),
    )
    // クライアントには汎用エラーを返すが、ログには詳細を残す
    return appError.New("internal_error", http.StatusInternalServerError)
}

ここで注意すべきは、ログ出力時にエラーを文字列化して終わりにしないことです。
slog.Anyでエラーオブジェクトそのものを渡せば、カスタムエラー型が持つフィールドも構造化されて出力されます。

次に、ロガーの設定フェーズで決めておくべきポイントを表にまとめました。

設定項目 推奨値 理由
出力フォーマット JSON ログ収集システム(Elasticsearch, Loki等)との親和性が高い
最小ログレベル 開発: debug, 本番: info 本番でdebugを有効にするとノイズが増えるが、errorは必ず出力
タイムスタンプ精度 ミリ秒またはマイクロ秒 分散トレーシングとの相関に十分な分解能を確保
ソースコード位置 ファイル名:行数(任意) デバッグ時に有益だが、出力サイズとのトレードオフを考慮

さらに、エラーハンドリングとロギングを連携させるうえで、エラーが発生したら即座にログを出力するのではなく、責務の境界(レイヤー)で一度だけ出力するというルールを徹底してください。
リポジトリ層でログを出し、ユースケース層でも出し、ハンドラ層でも出すと、同じエラーでログが重複し、トレースが難しくなります。

では、具体的にどのレイヤーで出力すべきか。
私の推奨はユースケース(サービス)層とハンドラ層の2箇所です。
ユースケース層ではビジネスルールに違反したエラーを構造化ログに残し、ハンドラ層ではHTTPステータスコードとともに最終的なエラーを集約して出力します。
リポジトリ層ではエラーをラップして上位に投げるだけで、ログは出力しません。

最後に、本記事で一番伝えたいのは、エラーハンドリングは「起こったこと」の記録ではなく、「システムの状態遷移」の証跡として捉えるという視点です。
単なるスタックトレースの羅列ではなく、リクエストID、セッション情報、処理時間、関連する外部APIの応答コードなど、文脈を付与することで、ログは単なる文字列から「診断可能なデータ」へと変わります。

Goのエラー処理は確かに冗長になりがちです。
しかし、その冗長性をロガーとの連携で構造化された情報に変換できれば、運用時の大きな資産となります。
まずは、既存プロジェクトのエラー出力箇所を洗い出し、上記のルールを1つずつ適用してみてください。
明日からの障害対応が、きっと変わると確信しています。

  1. Goのエラーハンドリングが「運用を苦しくする」本当の理由
  2. エラーは値であり、文脈である――Goの哲学を再定義する
    1. 値としてのエラーを拡張する実践的アプローチ
    2. エラー種別(Kind)の設計がログ制御を変える
    3. エラーを「文脈」として扱うための型システムの活用
    4. 文脈を持たせることが運用設計の第一歩
  3. ロガー選定の前に決めるべき3つの運用要件
    1. 要件その1:出力フォーマットと収集システムとの親和性
    2. 要件その2:ログレベルの動的制御とサンプリング機能
    3. 要件その3:コンテキスト(Context)との統合設計
    4. 表で比較する3つの要件と主要ロガーの適合性
    5. 要件を言語化してからライブラリを評価する
  4. 構造化ロギングとエラーラッピングの統合設計
    1. エラーラッピングとログフィールドの一元管理
    2. レイヤーをまたがるエラー伝播とログの重複防止
    3. 構造化ログとトレーシングの融合
    4. 統合設計がもたらす3つの具体的な恩恵
    5. 設計のトレードオフと導入のハードル
  5. 実践コードで比較する3つのアプローチ(slog、logrus、zap)
    1. slog(標準パッケージ)のアプローチ
    2. logrus(構造化ロギングの先駆け)のアプローチ
    3. zap(高性能かつ構造化に強い)のアプローチ
    4. 3つのアプローチを比較する総合評価
    5. 結論として選ぶべき基準
  6. レイヤーごとのログ出力ルールを明確化する
    1. リポジトリ層および外部連携層:ログを出力しない
    2. ユースケース(サービス)層:ビジネスルール違反を構造化ログで記録
    3. ハンドラ層(最上位):全てのエラーの最終出口としてログ出力
    4. 例外ケース:バッチ処理や非同期ジョブでのルール
    5. ルールをチームで合意するためのチェックリスト
  7. 本番運用で役立つエラーIDとトレースIDの付与戦略
    1. トレースIDの生成と伝搬ルール
    2. エラーIDの役割と付与タイミング
    3. トレースIDとエラーIDの組み合わせで実現する高速デバッグ
    4. 外部サービスとのトレースID連携
    5. 導入時の注意点とトレードオフ
    6. まとめ:ID戦略はログ運用の「主キー」である
  8. ログレベル設計を誤ると本番で情報が死ぬ
    1. レベル設計の基本原則:エラーには常に十分な情報を
    2. 動的ログレベル変更の導入が本番情報消失を防ぐ
    3. エラー種別とログレベルのマッピングを体系化する
    4. サンプリング戦略で高頻度エラーを制御する
    5. レベル設計の見直しタイミング
  9. エラーログをアラートに繋げる閾値設計のコツ
    1. エラー種別ごとに異なる閾値を設定する
    2. 閾値のベースラインを収集する3段階アプローチ
    3. 構造化ログのフィールドを活用した条件付きアラート
    4. アラートの「疲労」を防ぐためのエスカレーションポリシー
    5. アラート設計で最も重要な「無音期間」の設定
    6. 閾値設計は「生きているドキュメント」である
  10. まとめ:エラーハンドリングとロギングは「運用設計」そのもの
    1. 本記事で提示した設計原則の総復習
    2. 導入は一度に全てをやる必要はない
    3. 運用設計は「終わり」ではなく「始まり」

Goのエラーハンドリングが「運用を苦しくする」本当の理由

Go言語のエラー処理で運用障害が発生する原因を示す複雑なコードとログの断片のイメージ

Go言語のエラーハンドリングは、明示的で堅牢な設計として評価される一方で、実際の運用フェーズに入ると「思いのほか疲れる」という声を耳にしませんか? 原因は、エラー処理そのものではなく、エラーが持つ情報の質と、それがログとしてどう出力されるかの設計にあります。

まず、Goのエラーはerrorインターフェースを満たす値に過ぎません。
この単純さが魅力である一方、標準パッケージだけではエラー発生時のスタックトレース、発生箇所の関数名、リクエストを一意に識別するIDといった運用必須のメタ情報が一切付与されません。
結果として、本番環境で出力されるログは、以下のような断片的な文字列の羅列になりがちです。

2026/08/21 15:04:05 connection refused
2026/08/21 15:04:07 invalid parameter: userID

この情報だけで、どのマイクロサービスで、どのAPIエンドポイントが、どのユーザーリクエストで発生したのかを特定できますか? ほとんどの場合、不可能です。
これが、Goのエラーハンドリングが「運用を苦しくする」第一の理由です。

次に、開発者が無意識に陥るアンチパターンとして、エラーの握り潰しと過剰なログ出力の両極端があります。
握り潰しとは、エラーを_で無視したり、単にlog.Printlnで出力して終了するケースです。
この場合、障害発生時に「何が起きたか」すら分からなくなります。
一方、過剰なログ出力は、すべてのレイヤーでlog.Errorを呼び出し、同じエラーが3重4重に記録される状態を指します。
この場合、ログのノイズが増え、本当に重要な警告が埋もれます。

さらに深刻なのは、エラー型の設計が曖昧なプロジェクトです。
カスタムエラー型を定義せず、fmt.Errorfでその都度メッセージを生成していると、エラーの種類(一時的なネットワーク障害なのか、バリデーションエラーなのか、権限不足なのか)をプログラム的に判定できません。
結果として、errors.Isやerrors.Asが使えず、ハンドラ層で一律に500エラーを返すことになり、クライアント側のリトライ制御やユーザーへの適切なフィードバックも難しくなります。

ここで、運用視点で考えるべき本質は、エラーは「失敗の記録」ではなく「システムの状態遷移を説明する証跡」 だということです。
その証跡に欠かせない要素を整理してみましょう。

  • エラーが発生したタイムスタンプ(ミリ秒単位)
  • エラーを発生させたオペレーション名(例:repository.FindUser, service.ProcessOrder)
  • リクエスト単位のトレースIDまたは相関ID
  • エラーのカテゴリ(一時的、恒久的、ビジネスルール違反など)
  • エラー発生時の入力パラメータ(個人情報を除く)

これらの要素が欠けた状態で蓄積されたログは、障害対応時に「なぜそうなったか」の再現を不可能にし、結果として平均復旧時間(MTTR) を大幅に伸ばします。
私自身、複数のGoプロジェクトを支援してきましたが、エラーログの質が低いチームは、障害対応に通常の3倍以上の時間を要するというデータもあります。

では、なぜこれほど単純な設計論が浸透しないのでしょうか。
理由の一つは、Goの公式ブログやチュートリアルが「エラー処理の基本」に焦点を当て、運用設計との連携まで踏み込んでいないからです。
もう一つは、開発フェーズではエラーが目立ちにくく、本番負荷がかかって初めて問題が顕在化するという性質にあります。

具体例を挙げましょう。
以下のようなコードは、開発中は問題なく動作します。

if err != nil {
    log.Printf("error: %v", err)
    return err
}

しかし、このコードが本番で1分間に1000回呼び出されたとき、出力されるログはタイムスタンプとエラーメッセージのみです。
これでは、どのリクエストパスで、どのユーザーセッションで、どのDBクエリが失敗したのか、一切追跡できません。
ログは「見える」ことよりも「繋がる」ことが重要です。
時系列順に並んでいれば良いのではなく、分散システムの中で複数サービスにまたがるエラーを一貫性をもって追跡できる設計が必要です。

この問題を解決するには、エラーハンドリングの段階で構造化コンテキストを付与し、それをロガーがそのまま受け取れる形で出力する仕組みが欠かせません。
そして、その仕組みは単なるライブラリの選定ではなく、チーム全体で合意されたルールとして実装されなければなりません。

次の章では、Goのエラーを「値」としてだけでなく「文脈」として扱うための設計思想を深掘りします。
まずは、現在のプロジェクトのエラーログを一つ拾い、そのログだけで障害の原因を特定できるかどうか、ぜひ自己診断してみてください。
その答えが、この記事を読み進めるべきかどうかの判断基準になるでしょう。

エラーは値であり、文脈である――Goの哲学を再定義する

Goのエラーインターフェースと値としてのエラーを抽象化した図解

Goの公式ドキュメントは、「エラーは値である」と繰り返し説きます。
この言葉の真意は、エラーを特別な制御構造ではなく、他の変数と同様に扱うべきという設計思想にあります。
しかし、多くの開発者はこの哲学を「エラーはただの文字列か整数コードで十分」と誤解していませんか? 本来、値であることと、文脈を持たないことは全くの別物です。

ここで重要なのは、エラーが「何が起こったか」だけでなく、「どのような状況で起こったか」を表現するデータ構造であるべきだという視点です。
Goのerrorインターフェースは最小限の契約(Error() stringメソッドのみ)を定めるだけなので、実装次第でいくらでもリッチな情報を付与できます。
問題は、その拡張性を活かす設計を最初から考えていないケースが圧倒的に多いことです。

値としてのエラーを拡張する実践的アプローチ

まず、カスタムエラー型を定義する際に、以下のフィールドを標準で持たせることを推奨します。

type AppError struct {
    Op         string    // 発生したオペレーション名
    Kind       ErrorKind // エラーの種類(一時的、恒久的、認証エラーなど)
    Err        error     // 元のエラー(ラップ元)
    Timestamp  time.Time
    RequestID  string
}

この構造体が持つ情報は、単なるメッセージ文字列よりもはるかに多くの示唆を提供します。
Opによってどの関数レイヤーで落ちたかが特定でき、Kindによってリトライすべきか、即時アラートを発報すべきかが判断できます。
そして、RequestIDによって同じリクエスト内の複数サービス間でのログ追跡が可能になります。

次に、このカスタムエラーを生成するヘルパー関数を用意します。
fmt.Errorfで%wを使ってラップするのも有効ですが、ラップするたびにオペレーション名を追加するルールを徹底してください。

func E(op string, err error, kind ErrorKind, reqID string) error {
    if err == nil {
        return nil
    }
    return &AppError{
        Op:        op,
        Kind:      kind,
        Err:       err,
        Timestamp: time.Now(),
        RequestID: reqID,
    }
}

このE関数を各レイヤーの境界で呼び出すことで、エラーが上位に伝播するたびに「どの関数を通過したか」というスタック情報に近いコンテキストが積み上がります。
ただし、Goのスタックトレースと異なり、あくまでビジネスレイヤー単位のオペレーション名を記録する点が特徴です。
これにより、runtime.Callerを使った重い処理を避けつつ、運用に十分なトレーサビリティを実現できます。

エラー種別(Kind)の設計がログ制御を変える

ここで、ErrorKindの設計はロギング戦略に直結します。
例えば、以下のような種別を定義しておくと、ログレベルや監視アラートの条件分岐が格段に容易になります。

  • Temporary:ネットワークタイムアウトやDB接続断など、リトライで回復可能なもの
  • Permanent:設定ミスやコードバグなど、修正なしでは回復しないもの
  • NotFound:リソース未検出(ビジネス上はエラーではなく準正常系)
  • Permission:認可エラー(ユーザー権限不足)
  • InvalidArgument:リクエストパラメータのバリデーションエラー

これらの種別をログ出力時に参照し、TemporaryならWARNレベル、PermanentならERRORレベル、NotFoundならINFOレベルで出力するなど、出力レベルを動的に制御できます。
これは、単にif err != nil { log.Error }と一律に出力するより、本番環境のログ量を最適化するうえで大きな効果をもたらします。

エラーを「文脈」として扱うための型システムの活用

さらに、Goの型システムを活用すれば、エラーが持つ文脈を静的にも保証できます。
例えば、errors.Isやerrors.Asを使ってエラー種別を判定する際、カスタム型がインターフェースメソッドを実装していれば、より柔軟なマッチングが可能です。

func (e *AppError) Is(target error) bool {
    t, ok := target.(*AppError)
    if !ok {
        return false
    }
    return e.Kind == t.Kind && e.Op == t.Op
}

このIsメソッドを実装しておけば、errors.Is(err, &AppError{Kind: Temporary})のような呼び出しで、一時的エラーかどうかを簡潔に判定できます。
この判定結果をログ出力やリトライロジックに反映させれば、コードの可読性と保守性が同時に向上します。

文脈を持たせることが運用設計の第一歩

結論として、Goの「エラーは値である」という哲学は、値をリッチに定義する自由を私たちに与えています。
その自由を活かさない手はありません。
開発フェーズでは「エラーメッセージだけあれば十分」と思えても、本番運用のストレス下では、その判断が後悔に変わることを多くの現場が証明しています。

次章では、このリッチなエラー情報をロガーとどう連携させるかという具体的な実装パターンに入ります。
まずは、プロジェクト内のエラー型を一つ選び、今説明したフィールドを追加した場合に、既存のログ出力がどう変わるかを試算してみると良いでしょう。
その差分が、運用コスト削減の第一歩です。

ロガー選定の前に決めるべき3つの運用要件

ロガー導入前に検討すべき運用要件をチェックリスト形式で示したアイキャッチ

Goのエコシステムには、slog、logrus、zap、zerologなど、実に多くのロギングライブラリが存在します。
機能比較記事は枚挙に暇がありませんが、ライブラリを選ぶ前に考えるべき優先順位が抜け落ちているケースがほとんどです。
ロガーは道具に過ぎず、その道具をどう使うかの運用要件が曖昧だと、どんな優れたライブラリを導入しても期待した効果は得られません。

ここでは、ロガー選定の前にチームで合意すべき3つの運用要件を整理します。
これらを明確にすることで、選択肢は自ずと絞られます。

要件その1:出力フォーマットと収集システムとの親和性

現代のシステム開発では、ログは単にファイルに書き出すだけでなく、Elasticsearch、Loki、CloudWatch Logs、BigQueryなど、何らかのログ収集・分析プラットフォームに送り込むことが前提です。
このとき、出力フォーマットが構造化されているかは最も重要な判断基準です。

  • JSON形式を標準サポートしているか
  • キー名に使える文字種に制約がないか(例:ドットやハイフンが使えるか)
  • ネスト構造を許容しているか(例:{ "error": { "kind": "timeout", "op": "db.query" } })

これらの要件を満たさないロガーは、たとえパフォーマンスが良くても、収集側でのパースやフィルタリングが複雑化します。
例えば、プレーンテキストで出力するロガーを選んだ場合、正規表現で切り出す処理を収集パイプラインに実装する羽目になり、運用コストが際限なく増えるでしょう。

要件その2:ログレベルの動的制御とサンプリング機能

本番環境では、すべてのエラーやデバッグ情報を詳細に出力していると、ストレージコストとネットワーク帯域を圧迫します。
そこで、環境ごとにログレベルを変更できる機能と、一定以上の頻度で発生するエラーをサンプリング(間引き)する機能は、運用設計の段階で検討しておくべきです。

Goの標準logパッケージにはこれらの機能がありませんが、多くのサードパーティライブラリは提供しています。
特に、slogはLevelVarを使えば実行時に動的にレベル変更が可能で、zapはSamplerを設定することで高頻度ログを間引けます。
ただし、サンプリング機能はエラーログに対しては無効化できるオプションがあるかも確認してください。
エラーだけは絶対に間引きたくないという要件は非常に一般的です。

要件その3:コンテキスト(Context)との統合設計

Goでは、リクエストスコープの情報を伝搬するためにcontext.Contextを多用します。
ロガーもこのコンテキストとシームレスに連携できることが必須要件です。
具体的には、以下のような機能が求められます。

  • contextからトレースIDやユーザーIDを自動的に抽出してログに付与できる
  • ハンドラチェーンの中で、同じコンテキストを使い回してもログ出力が一貫する
  • 子ゴルーチンで別のロガーインスタンスを使わずに済む

slogはWithContextやFromContextを標準でサポートしており、この点で非常に優れています。
一方、logrusはWithFieldsでコンテキスト値を手動で渡す必要があり、やや冗長になりがちです。
コンテキスト連携が自然に書けるかどうかは、実際のコードベースが大きくなればなるほど生産性に直結します。

表で比較する3つの要件と主要ロガーの適合性

以下に、代表的なロガーが上記3要件をどの程度満たしているかを簡易表にまとめました。
あくまで一例ですが、選定時の参考になるでしょう。

ロガー名 JSON出力標準対応 動的レベル制御 Context統合のしやすさ
slog(標準) ○(デフォルトでJSON) ○(LevelVar使用) ◎(標準APIで完結)
logrus ○(Formatter切替) ○(レベル指定可) △(手動でWithFields)
zap ○(高性能JSON) ○(AtomicLevel使用) ○(zap.WithContext拡張)
zerolog ○(高速JSON) ○(レベル動的変更可) △(Context対応は別途実装)

要件を言語化してからライブラリを評価する

多くのチームが「まずzapが速いと聞いたから」とか「logrusが人気だから」といった理由でロガーを決めてしまいがちです。
しかし、速度よりも運用要件の充足度が優先されるべきです。
100万リクエスト/秒を捌くような高負荷システムでなければ、slogとzapのパフォーマンス差は体感できないからです。

逆に、Context統合が不十分なロガーを選んでしまうと、リクエストIDをログに載せるたびにWithFieldを呼び出すコードが分散し、結果としてログの一貫性が崩れます。
これは、後から修正しようとすると全レイヤーに影響するため、非常にコストがかかる技術的負債です。

したがって、最初に行うべきは「我々のシステムでは何をログに残したいか」をリストアップし、それを収集・監視ツールがどう扱うかを設計図に描くことです。
その上で、その設計を最も自然に実装できるロガーを選ぶ。
この順序を逆にしないでください。

次の章では、この運用要件を満たしたうえで、構造化ロギングとエラーラッピングをどう統合するかという具体的な実装パターンに入ります。
まずは、現在のプロジェクトで使っているロガーがこの3要件を満たしているか、一度棚卸しをしてみることをお勧めします。

構造化ロギングとエラーラッピングの統合設計

構造化ログとエラーラッピングが連携するレイヤー図とデータフロー

これまでに述べてきた通り、エラーに文脈を持たせ、ロガーに運用要件を定義しただけでは、まだ十分ではありません。
本当の価値は、エラーラッピングと構造化ロギングを設計レベルで統合したときに発揮されます。
つまり、エラーが生成される瞬間にログ出力に必要な全てのフィールドが揃い、かつそのエラーがどのレイヤーを通過しても情報が欠損しない仕組みです。

この統合設計の核心は、エラーオブジェクト自体がログのペイロードとして機能するという発想の転換にあります。
エラーを単なる障害のシグナルではなく、構造化されたログイベントの一部として扱うのです。

エラーラッピングとログフィールドの一元管理

まず、エラーをラップする段階で、ログに含めたい全てのフィールドをエラー型に持たせることを徹底します。
前章で示したAppError構造体がその好例です。
ここで重要なのは、同じフィールドをロガーに渡すときに再度指定しないという原則です。
つまり、エラーが既に持っている情報は、ログ出力時に自動的に展開されるように設計します。

slogを例に取ると、カスタムエラー型にLogAttrsメソッドを実装することで、この統合を実現できます。

func (e *AppError) LogAttrs() []slog.Attr {
    return []slog.Attr{
        slog.String("op", e.Op),
        slog.String("kind", string(e.Kind)),
        slog.String("request_id", e.RequestID),
        slog.Time("timestamp", e.Timestamp),
        slog.Any("cause", e.Err), // 元のエラーも構造化保持
    }
}

そして、ハンドラ層でエラーをログ出力する際には、以下のように簡潔に記述できます。

if err != nil {
    appErr, ok := err.(*AppError)
    if ok {
        slog.LogAttrs(ctx, slog.LevelError, "operation failed", appErr.LogAttrs()...)
    } else {
        slog.Error("unwrapped error", slog.Any("error", err))
    }
}

このパターンの利点は、ログ出力のコードがエラーの種類に依存しないことです。
新しいエラーフィールドを追加したくなった場合も、LogAttrsメソッドだけを修正すればよく、全ての出力箇所に影響が波及しません。

レイヤーをまたがるエラー伝播とログの重複防止

統合設計で特に注意すべきは、エラーが複数のレイヤーを通過する際に、同じログが重複して出力されないようにする仕組みです。
前章でも触れましたが、リポジトリ層でログを出し、ユースケース層でも出し、ハンドラ層でも出せば、同一エラーが三重に記録されます。

これを防ぐには、エラーが生成された時点ではログを出力せず、ラップしながら上位に伝播させるというルールを徹底します。
そして、最終的にハンドラ層(またはメインの制御ループ)で一度だけ構造化ログを出力するのです。
この設計により、ログの出力回数はエラー発生数と一致し、ノイズが劇的に減少します。

では、エラーがラップされるたびにオペレーション名が積み上がっていく場合、最終的なログにはどのオペレーション名を載せるべきでしょうか。
ここでは、最も外側(上位)のオペレーション名を採用し、内部のオペレーションはcauseチェーンとして保持するのが実践的です。
AppErrorのErrフィールドが再帰的にAppErrorを保持している場合、Unwrapメソッドでチェーンを辿り、ログ出力時にはフルパスを組み立てることも可能です。

構造化ログとトレーシングの融合

さらに、現代的なシステムではログだけでなく分散トレーシング(OpenTelemetryなど)とも連携することが求められます。
統合設計では、エラーが発生した時点でトレースIDやスパンIDをエラーオブジェクトに埋め込み、ログ出力時にそれらを自動で付与する仕組みを組み込みます。

type AppError struct {
    // ... 既存フィールド
    TraceID string
    SpanID  string
}

そして、LogAttrsメソッドにこれらのIDを追加することで、ログとトレースがシームレスに紐づきます。
このような設計にしておけば、ログのタイムスタンプを頼りにトレースを探すといった非効率な作業が不要になります。

統合設計がもたらす3つの具体的な恩恵

この設計を導入することで、開発チームは以下の恩恵を直接体感できるでしょう。

  • ログ出力コードの削減:各レイヤーでWithFieldを連呼する必要がなくなり、コード量が20〜30%削減されるケースもあります
  • エラー種別に応じたログレベルの自動判定:TemporaryならWARN、PermanentならERRORといった振り分けがエラー型だけで完結する
  • 監視システムとの連携が容易:構造化フィールドが一定のスキーマに従うため、GrafanaやDatadogでのダッシュボード作成が数時間で完了する

設計のトレードオフと導入のハードル

とはいえ、この統合設計にはトレードオフも存在します。
第一に、カスタムエラー型を全プロジェクトで統一する必要があり、初期設計のコストがかかります。
第二に、サードパーティライブラリが返す標準エラーはAppErrorでラップし直す手間が生じます。
しかし、これらの初期コストは、運用開始後の障害対応時間を考えれば十分にペイする投資です。

次の章では、実際のコードベースでこの統合設計をどう実装するか、レイヤーごとの出力ルールとして具体化します。
設計が綺麗に決まれば、実装はむしろ単純化されるということを、ぜひ体感してください。

実践コードで比較する3つのアプローチ(slog、logrus、zap)

Goの主要ロガーslogとlogrusとzapのログ出力サンプルを横並び比較した画面

ここまで設計論を中心に議論してきましたが、実際のコードレベルではどのような差異が生まれるのでしょうか。
本章では、代表的な3つのロガー(slog、logrus、zap)を用いて、同じエラーシナリオを処理する実装を比較します。
各ロガーの特徴を理解したうえで、自プロジェクトに最適な選択肢を判断する材料を提供します。

比較シナリオとして、「ユーザー情報をDBから取得するリポジトリ層で接続タイムアウトが発生し、それをユースケース層が受け取り、ハンドラ層で最終的にログ出力する」という典型的なケースを想定します。
それぞれのロガーで、構造化ログとエラーラッピングをどう統合するかを見ていきましょう。

slog(標準パッケージ)のアプローチ

slogはGo 1.21で標準搭載されたため、外部依存なしで利用できる最大の利点があります。
また、slog.LogAttrsを使えば、属性のスライスを効率的に渡せます。

// リポジトリ層(エラーをラップして返すのみ、ログは出力しない)
func (r *userRepo) FindUser(ctx context.Context, id string) (*User, error) {
    // DB接続タイムアウトを想定
    if err := r.db.PingContext(ctx); err != nil {
        return nil, &AppError{
            Op:        "repository.FindUser",
            Kind:      Temporary,
            Err:       err,
            RequestID: getRequestID(ctx),
        }
    }
    // ... 正常処理
}

// ハンドラ層(最終的に一度だけログ出力)
func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.uc.GetUser(r.Context(), userID)
    if err != nil {
        appErr, ok := err.(*AppError)
        if !ok {
            slog.ErrorContext(r.Context(), "unwrapped error",
                slog.Any("error", err))
            http.Error(w, "internal error", 500)
            return
        }
        // AppErrorのLogAttrsメソッドで属性展開
        slog.LogAttrs(r.Context(), levelForKind(appErr.Kind),
            "request failed", appErr.LogAttrs()...)
        http.Error(w, errorMessage(appErr.Kind), statusForKind(appErr.Kind))
        return
    }
    // ... 正常応答
}

slogの特徴は、Contextを標準で受け取る設計が徹底されている点です。
これにより、トレースIDやユーザーコンテキストを透過的に渡せます。
また、属性の型安全性もAttr型で保証されるため、誤った型を渡すミスがコンパイル時に検出されます。

logrus(構造化ロギングの先駆け)のアプローチ

logrusは古くから使われている実績のあるライブラリで、WithFieldチェーンによる柔軟な属性追加が特徴です。
ただし、slogに比べるとContext統合がやや手動寄りです。

// リポジトリ層(logrus専用のフィールドを持つエラーとは別に、ログ用のエントリを生成)
func (r *userRepo) FindUser(ctx context.Context, id string) (*User, error) {
    if err := r.db.PingContext(ctx); err != nil {
        // エラー自体にはフィールドを持たせず、logrusのEntryに保存するアプローチも多い
        return nil, fmt.Errorf("repository.FindUser: %w", err)
    }
    // ...
}

// ハンドラ層
func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.uc.GetUser(r.Context(), userID)
    if err != nil {
        // logrusではContextから値を取り出し、WithFieldsで明示的に付与
        reqID := getRequestID(r.Context())
        entry := logrus.WithFields(logrus.Fields{
            "request_id": reqID,
            "op":         "handler.GetUser",
        })
        // エラーの種類を判定するためにerrors.Asを使用
        var appErr *AppError
        if errors.As(err, &appErr) {
            entry = entry.WithField("kind", appErr.Kind)
        }
        entry.Error(err.Error())
        http.Error(w, "internal error", 500)
        return
    }
    // ...
}

logrusのアプローチは直感的で学習コストが低い反面、エラー自体にログフィールドを持たせる設計と相性が良くありません。
そのため、ログ出力時にWithFieldsで再度コンテキストを集める必要が生じ、レイヤーが増えるほど冗長になります。

zap(高性能かつ構造化に強い)のアプローチ

zapはUber社が開発した高速なロガーで、JSON出力と構造化フィールドに強みを持ちます。
zap.Fieldを使った型安全なロギングが可能です。

// リポジトリ層(zap用のフィールドをエラーとは別に保持)
func (r *userRepo) FindUser(ctx context.Context, id string) (*User, error) {
    if err := r.db.PingContext(ctx); err != nil {
        return nil, &AppError{
            Op:   "repository.FindUser",
            Kind: Temporary,
            Err:  err,
            // zapではRequestIDをlogger自体に保持させることも多い
        }
    }
    // ...
}

// ハンドラ層(zapのLoggerにContextを関連付ける拡張を使用)
func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) {
    // zapの拡張(zap.WithContext)でContextからloggerを取得
    logger := zap.FromContext(r.Context())
    user, err := h.uc.GetUser(r.Context(), userID)
    if err != nil {
        var appErr *AppError
        if errors.As(err, &appErr) {
            // zap.Fieldを使って構造化出力
            logger.Error("request failed",
                zap.String("op", appErr.Op),
                zap.String("kind", string(appErr.Kind)),
                zap.String("request_id", appErr.RequestID),
                zap.Error(appErr.Err),
            )
        } else {
            logger.Error("unwrapped error", zap.Error(err))
        }
        http.Error(w, "internal error", 500)
        return
    }
    // ...
}

zapはパフォーマンスが非常に高く、フィールドの型安全性もslogと同等以上です。
ただし、zap.Errorフィールドはエラーの文字列しか保持しないため、構造化エラー型の恩恵を活かすにはフィールドを個別に展開する必要があります。
これはslogのLogAttrsに比べるとやや手間がかかります。

3つのアプローチを比較する総合評価

以下の表に各ロガーの特徴を整理します。

評価軸 slog logrus zap
標準パッケージ ○(依存ゼロ) ×(外部依存) ×(外部依存)
Context統合の自然さ ◎(標準API) △(手動WithFields) ○(拡張で対応可)
エラー型との統合設計 ◎(LogAttrsで自動展開) △(都度フィールド指定) ○(フィールド個別展開)
実行速度 中程度 中程度 最速
学習コスト 低い 低い やや高い

結論として選ぶべき基準

新規プロジェクトで特に制約がなければ、slogを強く推奨します。
標準搭載であること、Context統合が自然であること、そしてエラー型との統合をLogAttrsで綺麗に実装できる点が決め手です。
logrusは既存の大規模コードベースで使われている場合にメンテナンス目的で選ぶ価値がありますが、新規採用は今後減少するでしょう。
zapは超低レイテンシが求められるシステムで検討に値しますが、その差分が体感できるケースは稀です。

どのロガーを選んだとしても、重要なのはエラーラッピングとログ出力の責務を明確に分離し、重複を避ける設計です。
次の章では、その設計をレイヤー単位で具体化するルールを解説します。

レイヤーごとのログ出力ルールを明確化する

ハンドラ・ユースケース・リポジトリ各レイヤーでのエラーログ出力責任を色分けした設計図

これまでエラーラッピングとロガーの統合設計について詳述してきましたが、最後のピースとしてどのレイヤーでログを出力するかという責務分担が欠かせません。
このルールが曖昧だと、どんなに優れたエラー型やロガーを導入しても、出力されるログは重複か欠落のどちらかに偏ってしまいます。

一般的なクリーンアーキテクチャやレイヤードアーキテクチャでは、ハンドラ層、ユースケース(サービス)層、リポジトリ層、そして外部APIクライアント層などが存在します。
ここでは、ログ出力は原則としてハンドラ層とユースケース層の2箇所に限定するというシンプルなルールを提案します。
それぞれの責務を明確に定義しましょう。

リポジトリ層および外部連携層:ログを出力しない

まず、データベースアクセスや外部API呼び出しを行う最下層では、ログ出力を一切行いません。
代わりに、発生したエラーをAppErrorでラップし、オペレーション名とエラー種別を付与して上位に返すことに専念します。

func (r *userRepo) FindByID(ctx context.Context, id string) (*User, error) {
    var user User
    err := r.db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        // ログ出力はしない。ただラップして返す
        return nil, E("repository.FindByID", err, KindTemporary, getRequestID(ctx))
    }
    return &user, nil
}

この層でログを出さない理由は、エラーが発生した事実は必ず上位で処理されるからです。
もしここでlog.Errorを呼び出すと、ハンドラ層で再度出力した際に重複が発生します。
また、リポジトリ層はビジネスロジックを持たないため、出力するメッセージも「DB接続エラー」など汎用的になりがちで、運用上の付加価値が低いのです。

ユースケース(サービス)層:ビジネスルール違反を構造化ログで記録

ユースケース層では、ビジネスルールに基づくエラー(例:残高不足、権限不足、重複登録)が発生した場合にのみ、構造化ログを出力します。
これは、システムの動作として「想定内の異常」であり、監視アラートではなく運用ダッシュボードでの可視化を目的とします。

func (uc *userUseCase) GetUser(ctx context.Context, id string, requesterID string) (*User, error) {
    // 権限チェック(ビジネスルール)
    if !uc.auth.HasPermission(requesterID, "view_user") {
        err := E("usecase.GetUser", ErrPermissionDenied, KindPermission, getRequestID(ctx))
        // この層でのログ出力は「ビジネスルール違反」に限定
        slog.WarnContext(ctx, "permission denied for user view",
            slog.String("requester", requesterID),
            slog.String("target", id),
        )
        return nil, err
    }
    user, err := uc.repo.FindByID(ctx, id)
    if err != nil {
        // リポジトリ層からのエラーはラップしてそのまま返す(ログ出力しない)
        return nil, E("usecase.GetUser", err, KindTemporary, getRequestID(ctx))
    }
    return user, nil
}

この層で重要なのは、ログ出力の条件をビジネスルールエラーに限定することです。
DB接続エラーやネットワークタイムアウトのようなインフラ系エラーは、ここではログを出さずにハンドラ層に任せます。
これにより、ユースケース層のログは「ビジネス上の意思決定に関わるイベント」として純度が保たれ、分析に使いやすくなります。

ハンドラ層(最上位):全てのエラーの最終出口としてログ出力

ハンドラ層は、アプリケーションの外にエラーを返す最終出口です。
ここでは、下位層から返された全てのエラーを一度だけ構造化ログとして出力します。
同時に、HTTPステータスコードやクライアント向けエラーメッセージへの変換もこの層で行います。

func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    user, err := h.uc.GetUser(ctx, userID, requesterID)
    if err != nil {
        appErr, ok := err.(*AppError)
        if !ok {
            // ラップされていないエラーは予期しないバグとしてERROR扱い
            slog.ErrorContext(ctx, "unexpected unwrapped error",
                slog.Any("error", err))
            http.Error(w, "internal server error", http.StatusInternalServerError)
            return
        }
        // ここで最終的なログ出力(エラー種別に応じてレベル変更)
        level := levelForKind(appErr.Kind)
        slog.LogAttrs(ctx, level, "request finished with error",
            appErr.LogAttrs()...)
        // クライアントレスポンス
        status := statusForKind(appErr.Kind)
        http.Error(w, messageForKind(appErr.Kind), status)
        return
    }
    // 正常レスポンス
    respondJSON(w, user)
}

この設計の美しさは、エラー発生からログ出力までのフローが一直線になる点です。
リポジトリで発生したエラーはラップされ、ユースケースで必要に応じてビジネスルールエラーが追加され、最後にハンドラで一度だけ出力される。
この単純なルールを守るだけで、ログの重複は完全に排除できます。

例外ケース:バッチ処理や非同期ジョブでのルール

バッチ処理やワーカープロセスなど、HTTPリクエスト/レスポンスの文脈がないケースでは、ハンドラ層に相当する「メインループ」や「ジョブエントリポイント」を最終出力箇所とします。
その内部で呼ばれるロジックは、リポジトリ層とユースケース層のルールをそのまま適用してください。

ルールをチームで合意するためのチェックリスト

このレイヤー別ルールを導入する際、チームで以下の項目を共有しておくとスムーズです。

  • 各レイヤーでログ出力を許可する条件を明文化する(例:ユースケース層はビジネスエラー専用)
  • ログ出力を行う関数を共通ヘルパーに集約し、slog.LogAttrsの呼び出し方を統一する
  • コードレビューで「このログは本当にここで出すべきか」を必ず確認するチェックリストを用意する
  • テストコードでもログ出力の回数や内容を検証するアサーションを追加することを推奨する

これらのルールが徹底されれば、開発者は「どこでログを出すか」で迷うことがなくなります。
迷いが減れば、その分だけエラーハンドリングの品質に集中できるのです。

次の章では、さらに実践的なトピックとしてエラーIDとトレースIDの付与戦略を掘り下げます。
分散環境でのログ追跡をどう実現するか、具体的な実装パターンを見ていきましょう。

本番運用で役立つエラーIDとトレースIDの付与戦略

リクエスト単位で付与されるエラーIDとトレースIDを関連付けた分散トレーシングの模式図

構造化ログとエラーラッピングが整備され、レイヤー別の出力ルールも明確になったとしても、分散システムやマイクロサービス環境では単一のリクエストが複数のサービスを跨ぎます。
このとき、各サービスで出力されたログを一意に紐付けられなければ、障害の根本原因を特定するのに膨大な時間がかかります。
そこで登場するのがエラーIDとトレースIDです。

これらは似て非なる概念です。
トレースIDはリクエスト全体を通じて不変の識別子であり、エラーIDは個々のエラーイベントに対して付与される一意なIDです。
両者を適切に設計・運用することで、ログの検索体験は劇的に向上します。

トレースIDの生成と伝搬ルール

トレースIDは、クライアントから最初のリクエストを受け取った時点で生成するか、クライアントが発行したIDをそのまま受け継ぐかの二通りがあります。
Goでは、context.Contextを使ってこのIDを全レイヤーに伝搬させるのが標準的な手法です。

// ミドルウェアでトレースIDを生成または抽出
func TraceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = uuid.New().String()
        }
        ctx := context.WithValue(r.Context(), traceIDKey{}, traceID)
        // レスポンスヘッダにも設定してクライアントに返す
        w.Header().Set("X-Trace-ID", traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// 各レイヤーで取得するヘルパー
func getTraceID(ctx context.Context) string {
    if v := ctx.Value(traceIDKey{}); v != nil {
        return v.(string)
    }
    return "unknown"
}

このトレースIDを、全てのログ出力に必ず含めることが鉄則です。
前章で示したAppError.LogAttrsにもtrace_idフィールドとして組み込んでおけば、エラー発生時のログにも自動で付与されます。

エラーIDの役割と付与タイミング

一方、エラーIDは個別のエラー発生ごとにユニークな値を割り当てます。
これは、同一トレース内で複数のエラーが連続して発生した場合でも、それぞれを区別するために重要です。
エラーIDには、タイムスタンプ+乱数や、短縮UUID(例:err_8f3a9b)を用いるのが一般的です。

エラーIDの付与タイミングは、エラーオブジェクトを新たに生成する瞬間、つまりE関数内で行います。
これにより、ラップされたエラーはそれぞれ独立したIDを持ち、ログ上でどのラップ段階で発生したのかを特定できます。

func E(op string, err error, kind ErrorKind, reqID string) error {
    if err == nil {
        return nil
    }
    return &AppError{
        Op:        op,
        Kind:      kind,
        Err:       err,
        RequestID: reqID,
        ErrorID:   generateShortID(), // err_XXXXXX 形式
        Timestamp: time.Now(),
    }
}

トレースIDとエラーIDの組み合わせで実現する高速デバッグ

この二つのIDをログに含めることで、以下のような検索フローが実現します。

  1. ユーザーから「エラーが発生した」という報告とともにトレースID(レスポンスヘッダや画面表示)が提供される
  2. ログ収集システムでtrace_id = "xxx"で検索し、該当リクエストの全ログを時系列で取得
  3. その中に含まれるエラーIDごとに、エラー種別やオペレーション名を確認
  4. 最初にエラーが発生したポイント(最下層のオペレーション)を特定し、原因を切り分け

このフローが機能するためには、全てのログエントリに必ずトレースIDを付与するという絶対条件を守る必要があります。
たとえINFOレベルの正常系ログでも、トレースIDは必ず含めてください。
そうすることで、正常時と異常時の振る舞いを同じトレース軸で比較できます。

外部サービスとのトレースID連携

さらに、外部APIやデータベースへのクエリにもトレースIDを伝搬させることを検討してください。
例えば、HTTPクライアントでX-Trace-IDヘッダを自動付与するラッパーを用意し、DBドライバではコメントとして/* trace_id=xxx */をクエリ文字列に埋め込む手法があります。
これにより、外部依存先で発生したエラーも同一トレースで追跡可能になります。

// HTTPクライアントラッパー例
func DoWithTrace(ctx context.Context, req *http.Request) (*http.Response, error) {
    traceID := getTraceID(ctx)
    req.Header.Set("X-Trace-ID", traceID)
    return http.DefaultClient.Do(req.WithContext(ctx))
}

導入時の注意点とトレードオフ

この戦略を導入するにあたり、いくつかの注意点も存在します。

  • IDの長さ:UUID v4(36文字)をそのまま使うとログのサイズが増加するため、短縮ハッシュ(例:先頭8桁)やulid形式の採用を検討してください
  • パフォーマンス:毎リクエストでUUID生成を行うのは軽量ですが、高負荷時には乱数生成のオーバーヘッドが無視できなくなる場合もあります。その場合は、事前生成されたIDプールを使うなどの工夫が必要です
  • プライバシー:トレースIDにユーザー情報やIPアドレスをエンコードしないでください。あくまでランダムな識別子に留めるべきです

まとめ:ID戦略はログ運用の「主キー」である

トレースIDとエラーIDは、ログデータベースにおける複合主キーのような役割を果たします。
これらが整備されていないシステムでは、ログ検索が「タイムスタンプとエラーメッセージの部分一致」という非効率な手法に依存せざるを得ません。
ID戦略を最初に設計することで、運用フェーズの問い合わせ対応時間が半分以下になることは珍しくありません。

次の章では、これらのIDを活用したログレベルの設計とサンプリングについて、より実践的な観点から掘り下げます。
特に本番環境での情報損失を防ぐには、レベル設計が鍵を握ります。

ログレベル設計を誤ると本番で情報が死ぬ

開発環境と本番環境でのログレベルの違いと情報損失のリスクを表した比較表

これまでエラーラッピングやID戦略、レイヤー別ルールを丁寧に設計してきたにもかかわらず、ログレベルの設計を軽視すると、本番環境でせっかくの豊富な情報が全く活用されないという悲劇が起こります。
ログレベルは単なる「出力のON/OFF」ではなく、運用フェーズごとにどの情報を残し、どの情報を捨てるかという重要な意思決定です。

多くのチームが開発環境ではDEBUGレベルで詳細なログを出力し、本番ではINFO以上に切り替えるという単純な戦略を取ります。
しかし、この二値的な切り替えでは、障害発生時に「DEBUGでしか出ない情報」が永遠に失われます。
本番でエラーが起きたときに、原因特定に必要なコンテキストがDEBUGレベルにしか埋め込まれていない、という状況は避けなければなりません。

レベル設計の基本原則:エラーには常に十分な情報を

最初に理解すべきは、エラーログにはERRORレベルであっても、DEBUG相当の詳細情報を含めるべきという原則です。
つまり、ログレベルは「出力するかしないか」ではなく「この情報はどのくらい重要か」という重要度の指標として設計します。

具体的には、以下のようなレベル定義を推奨します。

  • ERROR:システムが正常に動作できない深刻な状態。即時対応が必要。必ずスタックトレースやエラーID、リクエストパラメータを含める
  • WARN:処理は継続できるが、注意を要する状態(例:リトライ成功、レート制限接近)。エラー種別がTemporaryの場合はWARNにマッピングする
  • INFO:ビジネス上の重要なイベント(例:ユーザー登録、決済完了)。監視ダッシュボードで可視化する対象
  • DEBUG:開発時のみの詳細トレース。本番では基本的に出力しないが、エラー発生時に動的に有効化できる仕組みを別途用意する

動的ログレベル変更の導入が本番情報消失を防ぐ

本番環境でDEBUGログを常時出力するのはストレージコストとパフォーマンスの観点から現実的ではありません。
しかし、障害発生時に一時的にDEBUGレベルを有効化できれば、原因特定のための貴重な情報を得られます。
この仕組みを動的ログレベル変更と呼びます。

slogではLevelVarを使うことで、実行時にレベルを変更できます。

var logLevel = new(slog.LevelVar)
logLevel.Set(slog.LevelInfo) // デフォルトはINFO

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: logLevel,
}))

// 管理用エンドポイントでレベルを動的に変更
func setDebugHandler(w http.ResponseWriter, r *http.Request) {
    logLevel.Set(slog.LevelDebug)
    w.Write([]byte("log level set to DEBUG"))
}

このエンドポイントを内部ネットワークからのみアクセス可能にしておけば、障害発生時にオペレーターが一時的にDEBUGを有効化し、詳細ログを取得した後でINFOに戻す、という運用が可能です。
これにより、本番で情報が死ぬという事態を回避できます。

エラー種別とログレベルのマッピングを体系化する

前章までで定義したErrorKindとログレベルを体系的にマッピングすることで、ログ出力の判断を自動化できます。
以下に推奨マッピングを示します。

ErrorKind 推奨ログレベル 運用上の扱い
Temporary WARN リトライで回復見込み。即時アラートは不要
Permanent ERROR コード修正が必要。即時アラート発報
NotFound INFO 準正常系。ビジネスフロー上の分岐として記録
Permission WARN セキュリティ監視対象。頻発時は調査
InvalidArgument INFO クライアント起因。エラーレスポンスに含める

このマッピングに従えば、ハンドラ層でlevelForKind(appErr.Kind)という関数を呼ぶだけで、適切なレベルが自動選択されます。
これにより、開発者が毎回レベルを手動指定する必要がなくなり、ヒューマンエラーによるレベルの不適切設定を防げます。

サンプリング戦略で高頻度エラーを制御する

次に考慮すべきは、高頻度で発生するエラーへの対応です。
一時的なネットワーク障害などでWARNログが毎秒数百件出力されると、ログ収集システムの負荷が増大し、重要なERRORログが埋もれる可能性があります。

この対策として、サンプリング(間引き) を導入します。
ただし、エラーログに対しては完全なサンプリングは避け、一定時間内の最初のN件は全て出力し、以降は10件に1件だけ出力するなどの「スロットリング」方式が推奨されます。

// 疑似コード:カウンタベースのスロットリング
var errorCounter atomic.Int64
func shouldLogError() bool {
    count := errorCounter.Add(1)
    if count <= 100 { // 最初の100件は全て出力
        return true
    }
    return count%10 == 0 // 以降は10件に1件
}

このような制御をロガーのラッパーに組み込むことで、ログボリュームを抑えつつ、エラーの傾向を把握するためのサンプルを残せます。

レベル設計の見直しタイミング

ログレベル設計は一度決めて終わりではありません。
本番運用を開始してから数週間後には、実際のログ出力量と監視アラートの頻度を基に調整が必要です。
例えば、WARNが予想以上に多くてノイズになっている場合、一部をINFOに引き下げる、あるいは逆にERRORに上げるなどのチューニングが求められます。

この見直しを定期的に行うために、ログ量のモニタリングダッシュボードを用意し、レベル別の出力件数を可視化しておくことを強くお勧めします。
データに基づいたレベル調整こそが、本番環境での情報死を防ぐ最善策です。

次の章では、こうして設計されたエラーログを監視アラートにどう繋げるかという実践的な閾値設計について解説します。
ログは見るためだけではなく、能動的にシステムを守るために使われるべきです。

エラーログをアラートに繋げる閾値設計のコツ

エラーログの頻度や重要度から監視アラートを発報するフロー図

ここまで積み上げてきた構造化ログとエラー設計の最終的なゴールは、単なる記録ではなく、能動的なシステム監視にあります。
エラーログがいくら詳細でも、人間がダッシュボードを眺めていなければ気づかれません。
そこで重要になるのが、エラーログをアラートに変換する仕組み、とりわけ閾値(しきいち)設計です。

アラート設計で最も陥りやすい失敗は、「エラーが1件でも発生したら即アラート」という過剰反応と、「ERRORログが1000件溜まるまで放置」という過少反応の両極端です。
適切な閾値は、エラーの種別、頻度、影響範囲に基づいて科学的に設定する必要があります。

エラー種別ごとに異なる閾値を設定する

前章で定義したErrorKindを活用すれば、エラーの性質に応じて閾値を細分化できます。
一律の閾値では、一時的なネットワークエラーと致命的なデータ不整合が同じ扱いになり、ノイズか見逃しのどちらかを招きます。

以下に、種別ごとの推奨閾値設計を示します。

  • Permanent(恒久的エラー):1件でも即時アラート。これはコードバグや設定ミスが原因である可能性が高く、放置するとシステム全体に影響を及ぼします
  • Temporary(一時的エラー):5分間で10件以上を閾値に設定。単発のリトライは正常運用の範囲ですが、短時間に多発する場合はネットワーク障害やDB負荷を示唆します
  • Permission(権限エラー):5分間で50件以上を閾値に。不正アクセスや設定誤りの可能性を監視しますが、通常ユーザーの操作ミスも含むため、やや緩めに設定します
  • InvalidArgument(バリデーションエラー):基本的にアラート対象外。クライアント起因のエラーは監視よりも、API利用元へのフィードバックで対応します

閾値のベースラインを収集する3段階アプローチ

閾値を適切に設定するには、正常時のエラー発生頻度(ベースライン)を知ることが不可欠です。
以下の3段階のプロセスを推奨します。

  1. 観測期間:新規システムの場合、最初の2週間は全てのエラーログを収集し、種別ごとの発生頻度をヒストグラム化します
  2. 統計的処理:平均値と標準偏差を算出し、平均+3σ(シグマ) を暫定的な閾値とします。これにより、統計的に異常と見なせる水準を設定できます
  3. 運用調整:実際のアラート発報率をモニタリングし、1日あたりのアラート数が運用チームの処理可能範囲(例:1日10件以内)に収まるよう、閾値を手動で微調整します

構造化ログのフィールドを活用した条件付きアラート

単なる件数カウントだけでなく、構造化ログの特定フィールドに基づいた条件付きアラートを設定すると、より精密な監視が可能になります。
例えば、以下のような条件を組み合わせます。

  • opフィールドが「payment.charge」かつkindが「Permanent」の場合、即時PagerDuty発報
  • request_idに特定のプレフィックス(例:admin_)が含まれるリクエストでエラーが発生した場合、通常よりも閾値を1/2に引き下げる
  • trace_idが同一のエラーが3件以上連続して出現した場合、分散トランザクションの障害と見なしてアラート

これらの条件は、ElasticsearchのクエリやPrometheusのラベルベースのルールで実装できます。
重要なのは、アラート条件もコードレビューと同様にバージョン管理することです。
監視設定をGitで管理し、変更履歴を追跡できるようにしてください。

アラートの「疲労」を防ぐためのエスカレーションポリシー

閾値設計で忘れてはいけないのが、アラートのエスカレーションです。
同じアラートが何度も繰り返されると、運用担当者は次第に反応しなくなり(アラート疲労)、本当に重大な障害が見逃されるリスクが高まります。

そこで、以下のようなエスカレーションポリシーを導入します。

  • 初回発生:Slackなどのチャット通知(担当者が気づく)
  • 10分以内に同じエラーが再発:メール通知(より明示的に)
  • 30分以内に3回以上再発:PagerDutyや電話による緊急通知
  • 1時間経過しても継続:マネージャー層へのエスカレーション

このように段階的に通知手段と対象者を変えることで、運用チームの注意力を適切に維持できます。

アラート設計で最も重要な「無音期間」の設定

最後に、メンテナンス時間帯やデプロイ直後はアラートを抑制する「無音期間(サイレンス)」の設計も必須です。
深夜のデータバッチ処理中に一時的にエラーが多発するのは想定内であり、そのたびにアラートが鳴ると運用者の睡眠を妨げます。

スケジュールベースのサイレンス(例:毎日午前2時〜4時)と、デプロイイベントに連動した一時的なサイレンス(例:デプロイ開始から15分間)を組み合わせて実装しておけば、本質的な障害と運用上許容されるエラーを区別できます。

閾値設計は「生きているドキュメント」である

閾値は一度設定すれば終わりではなく、システムの成長やトラフィック変動に合わせて継続的に見直すべきものです。
四半期ごとにアラートの発報実績と実際の障害発生状況を振り返り、偽陽性(誤報)と偽陰性(見逃し)の割合を評価することをお勧めします。
このメトリクスを可視化することで、閾値設計の質が数値的に改善されていきます。

次の最終章では、これまでの全ての議論を総括し、エラーハンドリングとロギングを「運用設計」として捉えることの本質的な意味を再確認します。

まとめ:エラーハンドリングとロギングは「運用設計」そのもの

Go言語のエラーハンドリングとロギングが統合された運用ダッシュボードのイメージ

ここまで、Go言語におけるエラーハンドリングとロギングの統合設計について、エラー型の拡張からロガー選定、レイヤー別ルール、ID戦略、レベル設計、そしてアラート閾値に至るまで、幅広く議論してきました。
これらのトピックは一見するとバラバラに見えますが、全ては「運用で困らないシステムを作る」という単一の目的に収束します。

改めて強調したいのは、エラーハンドリングはコードの品質ではなく運用の品質を決める要素だという点です。
どれだけ優れたアルゴリズムやアーキテクチャを採用していても、エラーが発生したときに「何が起きているか」を正確に把握できなければ、システムはブラックボックスと化します。
そして、そのブラックボックスを開くための鍵が、構造化ログと適切なエラー文脈なのです。

本記事で提示した設計原則の総復習

ここで、これまでの各章で述べた核心的な原則を簡潔に振り返っておきましょう。

  • エラーは値であり、同時に文脈である:errorインターフェースを拡張し、オペレーション名、エラー種別、リクエストID、タイムスタンプを標準で持たせる
  • ロガーは運用要件から逆算して選ぶ:出力フォーマット、動的レベル制御、Context統合の3点を優先評価する。新規プロジェクトではslogが最有力
  • エラーラッピングとロギングを統合する:エラー型自体にLogAttrsメソッドを持たせ、ログ出力時のフィールド展開を自動化する
  • ログ出力はレイヤーごとに責務を分離する:リポジトリ層は出力せずラップのみ、ユースケース層はビジネスエラーに限定、ハンドラ層で最終的に一度だけ出力する
  • トレースIDとエラーIDを必ず付与する:リクエスト全体を追跡するトレースIDと、個別エラーを識別するエラーIDを組み合わせて、分散環境でのログ追跡を実現する
  • ログレベルはエラー種別に応じて自動判定する:ErrorKindとログレベルのマッピングを体系化し、本番での情報欠損を防ぐために動的レベル変更機構も用意する
  • アラート閾値は種別ごとに統計ベースで設定する:過剰通知と見逃しを防ぐためにベースラインを収集し、エスカレーションポリシーとサイレンス期間を合わせて設計する

導入は一度に全てをやる必要はない

これらの原則を全て一度に適用しようとすると、既存の大規模プロジェクトでは抵抗が大きいかもしれません。
その場合は、新規モジュールや新規マイクロサービスから段階的に導入することをお勧めします。
例えば、次に立ち上げるAPIサーバーだけでも、この設計方針に従って実装してみてください。
数ヶ月後の運用フェーズで、その差は明確に現れます。

また、既存プロジェクトに導入する場合は、リポジトリ層のエラーラッピングから始めると効果的です。
この層だけを修正しても、上位レイヤーへの影響が少なく、かつログの質が劇的に向上するからです。

運用設計は「終わり」ではなく「始まり」

最後に忘れてはならないのは、この設計は静的な完成形ではなく、運用とともに進化するものだという事実です。
システムのトラフィックパターンやエラー発生傾向は日々変化します。
その変化に合わせてログレベルを見直し、アラート閾値を調整し、新たなエラー種別を追加していく。
そのサイクルを回すことこそが、真の意味での「運用設計」です。

エラーハンドリングとロギングを「面倒な作業」と捉えるか、「システムの健康状態を可視化する最重要機能」と捉えるかで、チームの運用成熟度は大きく変わります。
本記事が、その考え方の転換に少しでも役立つのであれば、これ以上の喜びはありません。

皆さんのプロジェクトが、エラーという「シグナル」を活かした健全な運用設計で満たされることを願っています。
まずは、今日のリリースから、一つでも新しいルールを適用してみてください。
その積み重ねが、明日の障害対応時間を確実に短縮します。

コメント

タイトルとURLをコピーしました