C# Tips | ログ・例外・診断:例外ログ共通化

C# C#
スポンサーリンク

はじめに:例外ログ共通化は「どこで落ちても、同じ形で記録する」ための仕組み

業務システムが大きくなると、あちこちで例外をログに書くコードが散らばりがちになります。
catch (Exception ex) { logger.LogError(ex, "エラー"); } が、サービス層にもコントローラにもバッチにも点在していくイメージです。

そうなると、ログのフォーマットがバラバラになり、
「どのログがどの画面・どの処理のものか」が追いづらくなります。

そこで効いてくるのが 例外ログの共通化 です。
「例外が発生したときのログ出力」を、ひとつの場所(ユーティリティや共通ハンドラ)に集約してしまう考え方です。

ここでは、初心者向けに

例外ログ共通化の狙い
ILogger を使った共通メソッドの形
ASP.NET Core でのグローバル例外ハンドリング(ミドルウェア)
コンソールアプリやバッチでの共通ハンドラ

を、例題付きでかみ砕いて説明します。


例外ログ共通化の狙いを整理する

「どこで落ちても、同じ情報が必ず残る」状態を作る

例外ログを共通化する目的は、ざっくり言うと次のようなものです。

どの層で例外が起きても、最低限必要な情報(日時、場所、例外の種類、スタックトレースなど)が必ず残る
ログのフォーマットが統一され、後から検索・分析しやすくなる
各所で同じような try-catch-LogError を書かなくて済む

特に重要なのは、「例外ログの“抜け漏れ”を防ぐ」という視点です。
画面 A ではログを書いているのに、画面 B では書き忘れていた、という状態を避けたいわけです。

そのために、「例外が最終的にたどり着く場所」を決めて、
そこで必ずログを書くようにします。


まずは小さな共通メソッドから始める

ILogger を受け取って「例外を標準フォーマットで書く」メソッド

一番シンプルな共通化は、「例外をログに書くときのフォーマット」をひとつに決めることです。
ILogger を使って、こんなユーティリティを用意してみます。

using Microsoft.Extensions.Logging;

public static class ExceptionLogger
{
    public static void LogException(ILogger logger, Exception ex, string contextMessage)
    {
        // contextMessage には「どの処理中か」を入れる
        logger.LogError(
            ex,
            "{ContextMessage} | ExceptionType={ExceptionType}",
            contextMessage,
            ex.GetType().FullName);
    }
}
C#

使う側は、こう書くだけです。

try
{
    DoSomething();
}
catch (Exception ex)
{
    ExceptionLogger.LogException(_logger, ex, "ユーザー登録処理中にエラー");
    throw; // 必要なら再スロー
}
C#

ここでのポイントは、「ログのメッセージテンプレートを共通化している」ことです。
ContextMessageExceptionType のようなキー名を統一しておくと、後でログ検索やダッシュボード作成がしやすくなります。


ASP.NET Core でのグローバル例外ハンドリングと共通ログ

ミドルウェアで「アプリ全体の最後の砦」を作る

Web API や Web アプリでは、グローバル例外ハンドラ(ミドルウェア) を使って、
「どこで例外が起きても最後にここを通る」というポイントを作るのが定番です。

シンプルな例外ハンドリングミドルウェアは、こんな形になります。

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            ExceptionLogger.LogException(_logger, ex, $"Unhandled exception. Path={context.Request.Path}");
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            await context.Response.WriteAsync("An error occurred.");
        }
    }
}
C#

登録は Program.cs などで行います。

app.UseMiddleware<ExceptionHandlingMiddleware>();
C#

これで、コントローラやサービス層でキャッチされなかった例外は、
最終的にこのミドルウェアに到達し、必ず共通フォーマットでログが書かれます。

ここでの重要ポイントは、「“最後の砦”で必ず例外ログを書く場所をひとつ決める」ことです。
個々のコントローラでログを書き忘れても、最悪ここで拾えます。


コンソールアプリ・バッチでの「メイン関数の共通ハンドラ」

Main の一番外側で例外をまとめてログに書く

バッチやコンソールツールでは、Main の一番外側で例外をキャッチして、
共通の例外ログを書いてから終了する、というパターンが使えます。

using Microsoft.Extensions.Logging;

class Program
{
    static int Main(string[] args)
    {
        using var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.AddConsole();
            // 必要ならファイルや他のプロバイダも追加
        });

        var logger = loggerFactory.CreateLogger<Program>();

        try
        {
            Run(args, logger);
            return 0;
        }
        catch (Exception ex)
        {
            ExceptionLogger.LogException(logger, ex, "Unhandled exception in batch");
            return 1;
        }
    }

    static void Run(string[] args, ILogger logger)
    {
        // ここから先は、必要なところだけ個別に try-catch
        // キャッチされなかったものは Main でまとめてログ
    }
}
C#

こうしておくと、「どこかで取りこぼした例外」があっても、
必ず最後に共通フォーマットでログが残ります。

ここでの重要ポイントは、「アプリの“入口”または“出口”で、例外ログの共通ハンドラを置く」ことです。
Web ならミドルウェア、バッチなら Main、というイメージです。


例外ログ共通化で「必ず残したい情報」を決める

何をログに入れるかを先に設計しておく

共通化するなら、「例外ログには必ずこれを入れる」という項目を決めておくと強いです。
例えば、次のような情報が典型的です。

日時(これはロガー側が自動で付けてくれることが多い)
例外の型(ex.GetType().FullName
メッセージ(ex.Message
スタックトレース(ex.ToString() を渡せば含まれることが多い)
処理の文脈(どの画面・どの API・どのバッチ・どの ID か)

ILogger の構造化ログを使うと、キー付きで残せます。

public static void LogException(ILogger logger, Exception ex, string context, string? userId = null)
{
    logger.LogError(
        ex,
        "Unhandled exception. Context={Context}, UserId={UserId}, ExceptionType={ExceptionType}",
        context,
        userId ?? "(unknown)",
        ex.GetType().FullName);
}
C#

ここでの重要ポイントは、「“例外ログのフォーマット”をチームで合意し、それをユーティリティに閉じ込める」ことです。
あとからログ分析基盤(Elasticsearch、Application Insights など)に流すときにも、この一貫性が効いてきます。


例外ログ共通化と「例外ラップ」「再スロー」の関係

ラップや再スローと組み合わせて、責任の流れをきれいにする

これまで話してきた他のテクニックとも、例外ログ共通化は相性が良いです。

リポジトリ層
低レベル例外を業務例外にラップする(例外ラップ)。

サービス層
業務例外をキャッチして、文脈付きでログを書き、必要なら再スロー。

最上位層(ミドルウェアや Main)
キャッチされなかった例外を共通ハンドラでログ。

例えば、サービス層ではこう書けます。

public void ProcessOrder(int orderId)
{
    try
    {
        _orderRepository.Save(orderId);
    }
    catch (OrderRepositoryException ex)
    {
        ExceptionLogger.LogException(_logger, ex, $"注文処理中のリポジトリエラー。OrderId={orderId}");
        throw; // さらに上(UI やミドルウェア)に任せる
    }
}
C#

最上位のミドルウェアや Main では、
「Unhandled exception」として共通ログを書きます。

ここでの重要ポイントは、「例外ラップで“意味付け”、再スローで“責任のバトンリレー”、共通ログで“記録の一貫性”」という役割分担を意識することです。


まとめ:例外ログ共通化は“落ち方がバラバラでも、記録の形は揃える”ための基盤

例外ログ共通化の本質は、

アプリのどこで例外が起きても
最低限必要な情報が
同じフォーマットで必ず残るようにする

ことです。

押さえておきたいポイントをコンパクトにまとめると、こうなります。

例外ログのフォーマット(含める項目)を先に決めて、ユーティリティに閉じ込める。
Web ならミドルウェア、バッチなら Main など、「最後の砦」で必ず共通ログを書く。
個々の層では、必要に応じて文脈付きでログを書きつつ、取りこぼしは最上位で拾う。
ILogger の構造化ログを使うと、後からの検索・分析が圧倒的に楽になる。

ここまでイメージできていれば、
「その場その場でバラバラに LogError する」段階から抜けて、
“例外ログがきれいに揃ったシステム”を設計できるようになります。

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