連載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 障害が起きている場合(最短ルート)
- 時刻で切る(例: ±5〜10分)
- Error を拾う(失敗の中心)
-
CorrelationId=<値>で同じ操作のログ束を集める - 前後の Info/Warning を追って、入口(開始)と出口(成功/失敗)を確認する
- 対象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つ。
- 誰が見るログか(社内のみか/顧客へ渡るか)
- どこに出るログか(社内限定のログ基盤/ファイルか、顧客へ渡る・公開されうる場所か)
| ログの置き場/閲覧者 | スタックトレース | 目安 |
|---|---|---|
| 社内の運用基盤(アクセス制御あり) | 出す | 障害対応が速い |
| 顧客に見える画面/メッセージ | 出さない | 内部情報の露出 |
| クライアント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を渡して例外(スタックトレース)を残し、ローテートで容量死を防ぐ