1
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?

R05 【掟・判例】ログ設計の掟 再現不能を終わらせる三点セット ― 異常なし!!(ビシッ) は最悪の嘘

Last updated at Posted at 2026-01-15

連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index

ログは「後から読むメモ」ではありません。障害が起きたときに、状況を“検索で引ける証拠”に変える仕組みです。
ここでは C#/.NET の標準ロギング抽象 ILogger を前提に、探しやすいログの出し方と、運用で死なない(ローテート/個人情報/出力先)ための掟をまとめます。

最近よく聞く「構造化ログ」は、ログを“文章”ではなく“キーと値(プロパティ)”として残し、あとから条件で引けるようにする考え方です。ここを理解すると、ログ設計の効き方が変わります。
テキストログでも戦えますが、最後は部分一致検索(grep等)頼みになりがちです。表現ゆれで検索が死にます。調査の入口が細くなり、原因到達までが長くなります。

えっ?部分一致検索(grep等)頼みにならないんですか?と思ったら、読んでみてください。

構造化ログで効くことはシンプルです。

  • 失敗/特定ID/特定操作の迅速な絞り込み
  • 相関IDによる「1回の操作ログ束」の一括追跡
  • 集計・監視(遅延/失敗率)の土台

このページのゴール

  • 障害時の検索語彙と辿り方
  • Information/Warning/Error の用途別運用
  • ILogger 構造化ログの腹落ち(“読む”→“引く”)
  • Serilog導入と簡易ログの両取り

0. このページの読み方(困ったらここから)

ログ調査の入口は、まずこの3つです。

  • 時刻(発生前後の範囲)
  • 相関ID(CorrelationId: 1回の操作/1回の処理を束ねる鍵)
  • 対象ID(注文ID/画面名/外部API名など、影響範囲を絞る鍵)

0.1 障害が起きている場合(最短ルート)

  1. 時刻で切る(例: ±5〜10分)
  2. Error を拾う(失敗の中心)
  3. CorrelationId=<値> で同じ操作のログ束を集める
  4. 前後の Info/Warning を追って、入口(開始)と出口(成功/失敗)を確認する
  5. 対象ID(OrderId等)で絞って影響範囲を確定する

1ファイルが巨大になると 1) が地獄になります。だからローテートが効きます。

0.2 Errorが出ていないのに不具合がある場合

  • 疑うべき論点: ログ不足/レベル設定誤り(Error不在、例外握りつぶし、相関ID不在など)
  • 再現待ち地獄化のリスク(現地端末/制御/組み込みで顕在化しやすい罠)

0.3 "良いログ"はこう見える(まず完成形の出力例)

実装より先に、出力結果の完成形を置く。目で見て掟の意味を掴む方が速い。

  • INFO 2026-01-15 14:20:00.0100 CorrelationId=... Screen=受注編集 Action=保存 開始
  • INFO 2026-01-15 14:20:00.0800 CorrelationId=... Screen=受注編集 Action=保存 OrderId=123 DB=Update ElapsedMs=70 実行
  • ERROR 2026-01-15 14:20:00.0900 CorrelationId=... Screen=受注編集 Action=保存 OrderId=123 失敗 (Exception=SqlException...)

この3行が揃うと、調査はこう進む。

  • ERROR を見つける(中心) → CorrelationId で束ねる(同じ操作だけ) → 前後の INFO/WARN を辿る(道筋)

1. この回の前提: ILogger とは(まず土台)

C#/.NET にはロギングの共通の型として Microsoft.Extensions.Logging.ILogger がある。
※ 言語やBCLに組み込みの機能というより、.NETの公式拡張パッケージ群 Microsoft.Extensions.Logging による標準的な抽象です。
出力先(コンソール、ファイル、収集基盤など)は差し替え可能で、アプリ側は ILogger に向かって書くのが基本になる。

private readonly ILogger _logger;

public MyService(ILogger<MyService> logger)
{
    _logger = logger;
}

この形にしておくと、後から「出力先をSerilogに変える」「基盤に送る」などをやりやすい。


2. 構造化ログとは(文章に加えて"キーと値(プロパティ)"を残す)

2.1 何が違うのか(まず誤解を潰す)

テキストログは「値を文章に埋め込む」発想になりがちです。

// 文章依存検索(後から OrderId=123 で絞れない状態)
_logger.LogInformation($"Order updated. {orderId}");
_logger.LogInformation($"Order updated. OrderId=" + orderId);

補足:出力の見た目(Order updated. 123 になるか、Order updated. OrderId=123 になるか等)は出力先次第です。重要なのは、内部的に OrderId のようなプロパティが付いて残ることです(検索・集計に効きます)。

構造化ログは、値を"文章"に焼き付けず、"プロパティ"として保持する。

// プロパティ保持({OrderId} は置換ではなくキー保持)
_logger.LogInformation("Order updated. {OrderId}", orderId);

// 表示向けのキー名付与(検索用途ではなく見た目用途)
_logger.LogInformation("Order updated. OrderId={OrderId}", orderId);

ここが肝です。orderId という変数を埋め込むのではなく、OrderId という"項目"をログに追加している。
その結果、運用では OrderId=123 のように条件で引ける(メッセージの書き方に依存しない)。

構造化ログのメリット(何が解決するか)

  • 条件検索が速くなる(失敗だけ、対象IDだけ、特定操作だけ)
  • 表現ゆれに強くなる(文章が多少違ってもキーで揃う)
  • 集計/監視へ繋げやすい(失敗率、遅延、リトライ回数など)
  • 出力先を変えても筋が通る(ILogger の書き方は維持したまま、Serilog等へ差し替え可能)

2.2 出力はどう見える?(結論: 見た目は出力先次第)

ILogger 呼び出しは「OrderId=123 というプロパティを持ったログイベント」を作る。
画面にどう表示されるかは、出力先(プロバイダ/formatter)次第で変わる。

  • 例A(シンプルなテキスト): Order updated. 123
  • 例B(プロパティも付ける表示): Order updated. 123 (OrderId=123)
  • 例C(JSON): { ..., "RenderedMessage":"Order updated. 123", "Properties": { "OrderId": 123 } }

大事なのは "OrderId=123 がプロパティとして残る" こと。見た目は後で変えられる。


3. 構造化ログの書き方: NG/OK(検索で死ぬパターン)

NG(検索に弱い)

// NG: 文字列連結(キー不在・条件検索不可)
_logger.LogInformation("Order updated. " + orderId);

// NG: 補間(値の文章焼き付け・表現ゆれ耐性低下)
_logger.LogInformation($"Order updated. {orderId}");

// NG: 補間+連結(表現ゆれ増加・"OrderId"の検索語彙化失敗)
_logger.LogInformation($"Order updated. OrderId=" + orderId);

// NG: 例外の文字列化(型/スタック/Inner欠落・原因到達遅延)
try
{
    DoWork();
}
catch (Exception ex)
{
    _logger.LogError("Failed: " + ex);
    throw;
}

// OK: {OrderId} のプロパティ化(OrderId=123 での条件検索)
_logger.LogInformation("Order updated. {OrderId}", orderId);

// OK: 例外オブジェクトの受け渡し(型/スタック/Innerの証拠保持)
try
{
    DoWork();
}
catch (Exception ex)
{
    _logger.LogError(ex, "Failed: {Action}", action);
    throw;
}

OK(キーが残る)

// OK: キー保持(OrderId=123 での条件検索)
_logger.LogInformation("Order updated. {OrderId}", orderId);

// OK: 例外オブジェクトの受け渡し(型/スタック/Innerの証拠保持)
_logger.LogError(ex, "Failed: {Action}", action);

キー設計の小さな掟(初心者が踏む地雷)

  • キー名は短く、安定させる(運用の検索語彙になる)
  • 値は巨大にしない(オブジェクト丸ごと、画面全状態などは避ける)
  • 自由入力の文章は種類が増えすぎて検索が死ぬ(カテゴリ/コード/IDに寄せる)

4. レベル運用の掟("温度"ではなく用途で固定する)

ログレベルは気分で決めると壊れる。用途で決める。

レベル 用途(誰が何に使う) 典型 調査の入口
Information "何が起きたか"の道筋 操作開始/終了、外部I/O成功、処理時間 時刻→操作の流れ
Warning 失敗ではないが不穏(予兆) リトライ、遅延、入力補正 不穏の束→前後を見る
Error その操作は失敗 例外、失敗結果、外部API失敗 まずここを見る
Critical(Fatal) 継続不能/止めるべき 起動不能、復旧不能級 即対応・通知

補足: ILogger は最上位を Critical、Serilogは Fatal と呼ぶ。意味合いはほぼ同じで「止まる/止める」級。

4.1 "ログを見に行きました。何で検索しますか?"

最初の検索語は、ログレベルで変わります。

  • Information を探す: 操作の入口/出口(開始/成功/失敗)で流れを掴む、処理時間で遅い箇所を拾う
  • Warning: 予兆キー(リトライ回数/遅延ms/補正内容)での絞り込み
  • Error: 例外型/エラーコード/外部依存キー中心の特定 + 相関IDで前後追跡

5. 例外ログの掟(握り潰す=証拠隠滅)

例外を catch したのにログに残さず握り潰すと危険になる。
"たまに失敗する"が"何も起きていない"に見えてしまい、調査の入口が消える。
そしてそれは "危険な仕様" へ変わる(気付けない不具合になる)。

OK: 例外は必ず ex を渡す

try
{
    DoWork();
}
catch (Exception ex)
{
    _logger.LogError(ex, "Failed. {OrderId}", orderId);
    throw;
}

throw ex; は避ける(理由: 発生地点が追いにくくなる)

throw ex; は「投げ直した場所」を例外の発生地点として見せてしまいます。
結果として、元の発生地点へ辿る手がかり(スタックトレース)が弱くなり、原因特定が遅くなります。

投げ直すだけなら throw; を使います。throw;新しい例外を作りません。同じ例外をそのまま再送出し、元のスタックトレースを保ちます。

業務でよくある「上位へ意味のある例外に言い換えたい」場合は、再送出ではなく ラップ します。

try
{
    DoWork();
}
catch (SqlException ex)
{
    // 伝えたい意図の付与(業務側の意味づけ)
    throw new BusinessException("DB更新に失敗しました。", ex);
}
  • ラップ時の要点: InnerException で元の例外を保持
  • ログ時の要点: どちらか片方だけではなく、元例外(Inner)まで辿れる形で残す

スタックトレースを出す/出さない判断材料(現場の後悔まで含める)

スタックトレースは強い証拠ですが、露出範囲によっては危険にもなる。
ただし「最初は不要」と切った結果、運用で詰むことがある。特に再現しにくい制御/組み込み系はこの罠が多い。

  • スタック無しで運用開始
  • 原因に届かない
  • ログ処理を書き直す
  • 次に再現するまで待つ(最悪、お客様の再現待ち)

この地獄を避ける判断軸は2つ。

  1. 誰が見るログか(社内のみか/顧客へ渡るか)
  2. どこに出るログか(社内限定のログ基盤/ファイルか、顧客へ渡る・公開されうる場所か)
ログの置き場/閲覧者 スタックトレース 目安
社内の運用基盤(アクセス制御あり) 出す 障害対応が速い
顧客に見える画面/メッセージ 出さない 内部情報の露出
クライアントPCのログ(添付して送る想定) 原則は出すが注意 個人情報/秘密情報が混ざらない設計が前提
公開されうる場所(stdoutが共有ログに載る等) 慎重 露出範囲が読めない

結論: "出せない"なら "出せる場所" を別に用意する。
例: ファイルにだけ詳細を出す(アクセス制御された場所) + 画面/通知には相関IDだけ出す。


6. 相関IDの掟(ログを束ねて追跡する)

相関IDとは「この1回の処理に属するログ」を束ねるためのID。
同時に複数の処理が走るとログは混ざる。相関IDが無いと同じ処理のログ束を集められない。

補足: 「相関ID」は難しければ「操作ID」や「トランザクションID」と呼んでも構いません。
「ログ行のID」ではなく、「この1回の操作に属するログを束ねるID」という意味です。

6.0 相関ID(操作ID)の作り方例

  • GUID(N)採用: Guid.NewGuid().ToString("N")
  • 時刻+乱数採用: 20260115-102233.123-4f8a2c のように可読性と一意性を両立
  • 分散/トレース連携採用: Activity.TraceId を採用(ASP.NET等と揃える)

BeginScope の値をログへ出すには、ログ出力側(プロバイダ)がスコープに対応している必要があります。 例: Console ログは IncludeScopes の設定、Serilog は Enrich.FromLogContext() 等が前提です。

6.1 BeginScope は毎ログごとに必要?(結論: 1操作に1回)

BeginScope(...) は「この範囲に属するログへ共通プロパティを付ける」仕組み。
ログ1行ごとに書くものではなく、1操作(ボタン押下1回)や1処理(API1リクエスト)の入口で1回だけ using で囲む。

private void btnSave_Click(object sender, EventArgs e)
{
    var correlationId = Guid.NewGuid().ToString("N");

    using var scope = _logger.BeginScope(new Dictionary<string, object?>
    {
        ["CorrelationId"] = correlationId,
        ["Screen"] = "受注編集",
        ["Action"] = "保存",
    });

    _logger.LogInformation("開始");
    try
    {
        SaveOrder();
        _logger.LogInformation("成功. {OrderId}", _orderId);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "失敗. {OrderId}", _orderId);
        throw;
    }
}

6.2 スコープ設定を短くする(拡張メソッド)

public static class LoggerScopeExtensions
{
    /// <summary>画面操作単位のスコープ開始</summary>
    public static IDisposable BeginOperationScope(
        this ILogger logger,
        string correlationId,
        string 画面,
        string 操作)
    {
        return logger.BeginScope(new Dictionary<string, object?>
        {
            ["CorrelationId"] = correlationId, // IDは固定(検索で使う)
            ["Screen"] = 画面,                 // 値は日本語でよい
            ["Action"] = 操作,                // 値は日本語でよい
        });
    }
}

使用例:

var correlationId = Guid.NewGuid().ToString("N");
using var scope = _logger.BeginOperationScope(correlationId, "受注編集", "保存");

補足: スコープの中身を実際にログへ出すかどうかは出力先(プロバイダ)次第。
Serilogを使う場合は Enrich.FromLogContext() を入れるとスコープのプロパティが出力に乗る。


7. 個人情報/秘密情報の掟(出さない)

ログは広がる(共有される、集約される、保存される)。
個人情報や秘密情報が混ざると対応は重くなる。

まず出さないもの(代表例)

  • 氏名、メール、電話、住所
  • パスワード、アクセストークン、APIキー、秘密鍵
  • クレジットカード番号、口座情報

どうしても識別が要るなら、マスク/匿名化ID/問い合わせIDに寄せる。


8. ログファイル運用の掟(命名/置き場/ローテート/標準出力)

8.1 ローテートする意味(メリット)

  • ストレージ枯渇の防止(上限がある)
  • 巨大ファイル事故の回避(コピー/圧縮/送付/閲覧が速い)
  • 必要な期間だけ残す(保持日数/保持世代)

8.2 命名(おすすめ)

  • 日次: app_yyyyMMdd.log
  • 日次 + サイズ: app_yyyyMMdd_000.log のように連番

8.3 置き場(WinFormsの現実)

  • Program Files 配下は権限で詰みやすい
  • 置き場所の工夫(LocalApplicationData 等への寄せ)

8.4 標準出力へも流す(必要な場面)

ログをファイルだけに寄せると、環境によっては集約できず詰む。
stdout/stderr にも流せると運用の選択肢が増える。

効く場面:

  • stdout自動収集環境(CI/サービス/コンテナ等)
  • ローカル検証で dotnet run の出力として追いたい
  • ファイルが書けない環境の第二経路として残したい

9. NuGetで完結させるなら: Serilog(構造化ロギング特化が強い理由)

テキストログは「人が読む文章」が中心になりやすい。
Serilogは最初から "イベント + プロパティ" を主役にしている。ここが強い。

  • プロパティで絞れる(検索が速い)
  • 集計・可視化に強い(メトリクス化しやすい)
  • 共通プロパティ(相関ID等)を毎回書かずに付与しやすい
  • 出力先(sink)を追加する前提で、ファイル/コンソール/基盤へ流しやすい

9.1 使い方のイメージ(何ができるか)

(1) 値をプロパティとして残す

Log.Information("Order processed. {OrderId} {Result} {ElapsedMs}",
    orderId, result, elapsedMs);

文章の一部ではなく、OrderId/Result/ElapsedMs が値として残る。
運用側は Result="NG" のように引ける(部分一致に頼らない)。

(2) オブジェクトを展開して残す(個人情報は入れない)

Log.Information("Order snapshot: {@Order}", order);

(3) 付帯情報を自動で足す(書き忘れを減らす)

Log.Logger = new LoggerConfiguration()
    .Enrich.WithThreadId()
    .CreateLogger();

9.2 使いこなすと何が解決するか

  • "再現できない" → "同じ条件のログ束を拾える"へ変わる(相関ID + プロパティ検索)
  • 気合で読む → 条件で引く(解決までの道のりが短くなる)
  • 監視・アラートが作りやすい(特定のプロパティに反応できる)

10. Serilog最小構成例(ILoggerで書き続ける)

目的は「アプリ側は ILogger の書き方を守り、出力先とローテートを委譲する」こと。

using Microsoft.Extensions.Logging;
using Serilog;

var logDir = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "YourApp",
    "logs");
Directory.CreateDirectory(logDir);

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .WriteTo.File(
        path: Path.Combine(logDir, "app-.log"),
        rollingInterval: Serilog.RollingInterval.Day,
        retainedFileCountLimit: 14,
        fileSizeLimitBytes: 10 * 1024 * 1024,
        rollOnFileSizeLimit: true)
    .WriteTo.Console()
    .CreateLogger();

using var loggerFactory = LoggerFactory.Create(builder =>
{
    builder.AddSerilog(Log.Logger, dispose: true);
});

ILogger logger = loggerFactory.CreateLogger("App");
logger.LogInformation("開始. {Action}", "Boot");
.NET Framework 4.8 の場合(差分が有意なときだけ)
  • Microsoft.Extensions.Logging を導入して ILogger へ寄せる方が筋が良い。
  • 出力先は事情で差が出るので、まずは「構造化」「レベル」「例外」「相関ID」を優先して固定する。

11. 自作で最低限を書くなら: SimpleRotateLogger(簡易ログ・そのまま使える版)

NuGetが使えない、事情で外部ライブラリを入れられない現場もある。
その場合でも "ないよりいい" で終わらせず、最低限の運用を支える自作ロガーを持っておくと強い。

狙い:

  • ローテート(日次 + サイズ)と保持日数
  • 置き場(LocalApplicationData等)の考慮
  • 例外を残す(スタック含む)
  • 必要なら stdout へも流す
  • 複数スレッドから呼ばれても壊れない(最低限の排他)

SimpleRotateLogger(コメント多め/そのまま使える)

この程度の自作でも、次の用途なら普通に使える。

  • まずファイルに残す(顧客PCの調査、現地作業、ローカル検証)
  • ローテート/保持で容量枯渇を防ぐ
  • 例外(スタックトレース)を残して原因に届く
SimpleRotateLogger 全コード(コメント多め/summary・param付き)
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

/// <summary>
/// 簡易ログ(SimpleRotateLogger)
/// - 日次+サイズローテート
/// - 保持日数超過ログの削除
/// - ファイル出力 + 標準出力(TextWriter、任意)
/// - マルチスレッド耐性(排他)
///
/// 長期運用時の追加要件(非同期キュー、設定外出し、マスク/匿名化、集約基盤送信など)
/// </summary>
public sealed class SimpleRotateLogger : IDisposable
{
    /// <summary>排他用ロック(同時書き込み破壊防止)</summary>
    private readonly object _gate = new();

    /// <summary>ログ出力ディレクトリ(権限事故回避: LocalApplicationData等)</summary>
    private readonly string _logDir;

    /// <summary>ログファイル名先頭(例: yourtool → yourtool_yyyyMMdd_000.log)</summary>
    private readonly string _baseName;

    /// <summary>1ファイル最大サイズ(超過時ローテート)</summary>
    private readonly long _fileSizeLimitBytes;

    /// <summary>ログ保持日数(古いログ削除・容量枯渇回避)</summary>
    private readonly int _retainedDays;

    /// <summary>標準出力出力先(Console.Out等、不要時null)</summary>
    private readonly TextWriter? _stdout;

    /// <summary>現行日付(日次ローテート判定)</summary>
    private DateTime _currentDate = DateTime.MinValue.Date;

    /// <summary>現行書き込み先(StreamWriter、ローテート時差し替え)</summary>
    private StreamWriter? _writer;

    /// <summary>現行ログファイルパス(サイズ判定/追記先判定用途)</summary>
    private string? _currentPath;

    /// <summary>
    /// ロガー初期化
    /// </summary>
    /// <param name="logDir">ログ出力ディレクトリ(未存在時作成)</param>
    /// <param name="baseName">ログファイル名先頭部分(例: app → app_yyyyMMdd_000.log)</param>
    /// <param name="stdout">標準出力出力先(Console.Out等、不要時null)</param>
    /// <param name="fileSizeLimitBytes">1ファイル最大サイズ(超過時ローテート)</param>
    /// <param name="retainedDays">保持日数(超過ログの削除対象)</param>
    public SimpleRotateLogger(
        string logDir,
        string baseName,
        TextWriter? stdout = null,
        long fileSizeLimitBytes = 10 * 1024 * 1024,
        int retainedDays = 14)
    {
        _logDir = logDir;
        _baseName = baseName;
        _stdout = stdout;
        _fileSizeLimitBytes = fileSizeLimitBytes;
        _retainedDays = retainedDays;

        Directory.CreateDirectory(_logDir);
    }

    /// <summary>
    /// Information相当ログ出力
    /// </summary>
    /// <param name="message">メッセージ本文</param>
    /// <param name="props">追加キー/値(例: CorrelationId, Action, OrderId、不要時null)</param>
    public void Info(string message, IReadOnlyDictionary<string, object?>? props = null) =>
        Write("INFO", message, null, props);

    /// <summary>
    /// Warning相当ログ出力
    /// </summary>
    /// <param name="message">メッセージ本文</param>
    /// <param name="props">追加キー/値(不要時null)</param>
    public void Warn(string message, IReadOnlyDictionary<string, object?>? props = null) =>
        Write("WARN", message, null, props);

    /// <summary>
    /// Error相当ログ出力
    /// </summary>
    /// <param name="message">メッセージ本文</param>
    /// <param name="ex">例外(スタックトレース含む)。不要ならnull。</param>
    /// <param name="props">追加キー/値(不要時null)</param>
    public void Error(string message, Exception? ex = null, IReadOnlyDictionary<string, object?>? props = null) =>
        Write("ERROR", message, ex, props);

    /// <summary>
    /// 1行ログ書き込み(簡易ログの最小実装)
    /// props付与形式("Key=Value"、本格構造化はSerilog等へ委譲推奨)
    /// </summary>
    /// <param name="level">ログレベル文字列(例: INFO/WARN/ERROR)。</param>
    /// <param name="message">メッセージ本文</param>
    /// <param name="ex">例外(あれば ex.ToString() を出力)。</param>
    /// <param name="props">追加キー/値(不要時null)</param>
    public void Write(string level, string message, Exception? ex, IReadOnlyDictionary<string, object?>? props)
    {
        var now = DateTime.Now;
        var propText = props is null ? "" : " " + FormatProps(props);

        var line = ex is null
            ? $"{now:yyyy-MM-dd HH:mm:ss.ffff} {level}{propText} {message}"
            : $"{now:yyyy-MM-dd HH:mm:ss.ffff} {level}{propText} {message}{Environment.NewLine}{ex}";

        lock (_gate)
        {
            EnsureWriter_NoLock(now);

            _writer!.WriteLine(line);
            _writer.Flush();

            if (_stdout is not null)
            {
                _stdout.WriteLine(line);
                _stdout.Flush();
            }

            CleanupOldFiles_NoLock(now);
        }
    }

    /// <summary>
    /// props整形("A=1 B=xyz" 形式)
    /// 値の空白対策(引用符付与/JSON化への発展)
    /// </summary>
    /// <param name="props">追加プロパティ。</param>
    /// <returns>整形済み文字列。</returns>
    private static string FormatProps(IReadOnlyDictionary<string, object?> props)
    {
        var sb = new StringBuilder();
        foreach (var kv in props)
        {
            if (sb.Length > 0) sb.Append(' ');
            sb.Append(kv.Key);
            sb.Append('=');
            sb.Append(kv.Value);
        }
        return sb.ToString();
    }

    /// <summary>
    /// ログ出力先(writer)の準備
    /// - 日付が変わったら日次ローテート
    /// - サイズ上限を超えたら連番を増やしてローテート
    /// </summary>
    /// <param name="now">現在時刻。</param>
    private void EnsureWriter_NoLock(DateTime now)
    {
        var today = now.Date;

        var needNewFile = _writer is null || _currentDate != today;

        if (!needNewFile && _currentPath is not null)
        {
            var fi = new FileInfo(_currentPath);
            if (fi.Exists && fi.Length >= _fileSizeLimitBytes)
                needNewFile = true;
        }

        if (!needNewFile)
            return;

        _writer?.Dispose();
        _currentDate = today;

        // yourtool_yyyyMMdd_000.log
        var seq = 0;
        while (true)
        {
            var path = Path.Combine(_logDir, $"{_baseName}_{today:yyyyMMdd}_{seq:000}.log");

            if (!File.Exists(path))
            {
                _currentPath = path;
                break;
            }

            var fi = new FileInfo(path);
            if (fi.Length < _fileSizeLimitBytes)
            {
                _currentPath = path;
                break;
            }

            seq++;
        }

        // FileShare.ReadWrite: 障害時にログを開いても書き込みが止まりにくい
        var fs = new FileStream(_currentPath!, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
        _writer = new StreamWriter(fs, new UTF8Encoding(false)) { AutoFlush = true };
    }

    /// <summary>
    /// 超過保持ログ削除(簡易ログの最小実装)
    /// </summary>
    /// <param name="now">現在時刻。</param>
    private void CleanupOldFiles_NoLock(DateTime now)
    {
        var threshold = now.Date.AddDays(-_retainedDays);

        foreach (var file in Directory.EnumerateFiles(_logDir, $"{_baseName}_*.log"))
        {
            try
            {
                var fi = new FileInfo(file);
                if (fi.LastWriteTime.Date < threshold)
                    fi.Delete();
            }
            catch
            {
                // 簡易版: 削除できない場合にアプリを止めない。
                // 本番では削除失敗をWarn等で残す・監視する設計が必要。
            }
        }
    }

    /// <summary>
    /// ロガー破棄(ファイルハンドル解放)
    /// </summary>
    public void Dispose()
    {
        lock (_gate)
        {
            _writer?.Dispose();
            _writer = null;
        }
    }
}

使い方例(WinForms/ツール: ユーザーPCで発生する不具合を追う)

// 置き場を決める(Program Files配下は権限で詰みやすい)
var logDir = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "YourTool",
    "logs");

// Consoleに出すなら Console.Out を渡す。不要なら null。
using var log = new SimpleRotateLogger(
    logDir: logDir,
    baseName: "yourtool",
    stdout: Console.Out,
    fileSizeLimitBytes: 5 * 1024 * 1024,
    retainedDays: 14);

var correlationId = Guid.NewGuid().ToString("N");

log.Info("開始", new Dictionary<string, object?>
{
    ["CorrelationId"] = correlationId,
    ["Screen"] = "受注編集",
    ["Action"] = "CSV取込",
    ["FileName"] = "input.csv",
});

try
{
    ImportCsv("input.csv");

    log.Info("成功", new Dictionary<string, object?>
    {
        ["CorrelationId"] = correlationId,
        ["Imported"] = 1200,
    });
}
catch (Exception ex)
{
    log.Error("失敗", ex, new Dictionary<string, object?>
    {
        ["CorrelationId"] = correlationId,
        ["Screen"] = "受注編集",
        ["Action"] = "CSV取込",
    });

    throw;
}

12. 判例(OK/NG)と容赦なき断罪(レビュー観点)

ログはコードレビューで見落とされやすい。だから"観点"を固定する。

12.1 判例(OK/NG)

観点 OK例 NG例 理由(事故) レビューで見る所
構造化 LogInformation("Saved {OrderId}", id) "Saved " + id キーが残らず検索で詰む メッセージテンプレートに {Key} があるか
例外 LogError(ex, "Failed {Action}", a) LogError("Failed " + ex) スタックやInnerが弱い ex を渡しているか
相関ID BeginScope(...CorrelationId...) なし 同時実行でログが混ざり追えない 入口でスコープを張っているか
レベル 失敗は Error 失敗も Info Errorで引けず見落とす 失敗経路が Error になっているか
個人情報 問い合わせID/マスク 氏名/メール/トークン 運用事故・情報漏えい 個人情報/秘匿が混ざっていないか
容量 ローテート/保持 1ファイル無限 ストレージ枯渇で停止 ローテート設定/保持設定

12.2 容赦なき断罪(レビュー観点表)

観点 ありがちな見落とし 事故の形 指摘コメント例(直球禁止)
検索語彙 キー名が毎回違う(OrderId/OrderID) 検索が当たらない "キー名が運用の検索語彙になるため、表記を揃えたい"
入口/出口 開始/成功/失敗のどれかが無い 流れが追えない "開始/成功/失敗が揃うと調査の道筋が作れる"
例外証拠 ex を文字列化している 原因に届かない "例外は型とスタックが重要なので LogError(ex, ...) に寄せたい"
レベル揺れ Warning を Error にしている ノイズで埋まる "Warning は予兆、Error は失敗で使い分けたい"
個人情報 画面入力をそのまま出している 情報漏えい/後処理 "問い合わせID/マスクに寄せる設計にしたい"
容量 ファイル無限/削除失敗無視 容量枯渇で停止 "保持日数/削除失敗ログの扱いを決めたい"

13. まとめ(この回の掟を3行で)

  • 入口の確保(Information: 開始/成功/失敗、検索起点)
  • ログ束の一発回収(CorrelationId)
  • 証拠を残す: Error 以上は ex を渡して例外(スタックトレース)を残し、ローテートで容量死を防ぐ
1
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
1
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?