自作ロガーの最小要件と結合テストチェックリスト

· 更新日: · · Windows Development, Logging, Integration Testing, Test Design, Reliability

更新履歴(6件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
検証スクリプトのBOM検査が機能していませんでした。`Get-Content -Encoding utf8`は先頭のBOMを読み飛ばしてから行を返すため、BOM付きのファイルでも`ConvertFrom-Json`は普通に通り、検査が素通りします。デコードする前に先頭3バイトを生で読んで`EF BB BF`を見るようにしました。
セッションIDを時刻とPIDだけで作っていたのを直しました。クラッシュループで同じ秒のうちに起動し直したときや、ローカル時刻が巻き戻ったときに、Windowsが再利用したPIDと組み合わさって前回と同じIDになり得ます。`JsonLinesLogger`は`FileMode.Append`で開くため、別々の起動のレコードが1つのファイルに混ざり、「1起動 = 1セッション」という前提が黙って崩れます。GUIDを足して衝突しない形にしました。
必須項目の検査がプロパティ名の有無しか見ておらず、レベルの値がnullのようなレコードを通してしまう問題を直しました。表では「nullの混入」を見つけられると書いていたので、値と型まで検査する形にしました。
冒頭に対象読者と前提の表、および「先に自作しない選択肢を確認する」表を追加しました。JSON Linesの実レコード例と仕様上の制約、v1の書き込み部分のC#実装と呼び出し側、`sessionId`の定義と採番方法、壊れを検出する3観点とそれを回すPowerShellスクリプトを追加し、参考資料の節を新設しました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589754)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「自作ロガーの最小要件と結合テストチェックリスト」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589754 https://staging.comcomponent.com/blog/2026/04/02/001-custom-logger-minimum-requirements-and-integration-test-checklist/

DOI(最新版)
10.5281/zenodo.21589754
DOI(この版)
10.5281/zenodo.21732771

既製の logging framework を使えるなら、そのほうが安全です。それでも、アプリケーション側の制約や運用上の事情で自作 logger を避けられない場面はあります。そこで最初に悩みやすいのが、どこまで実装すれば「雑すぎず、重すぎない」設計になるのか、という点です。

本記事では、対象を障害調査用のアプリケーションログに絞ります。監査証跡、分散トレーシング、メトリクス基盤、クラウド集約まで一度に背負い込まず、まずは現場で役に立つ最小構成を定義し、その構成を本当に信用できるようにするための結合テスト観点を整理します。

対象読者と、この記事の前提

項目 内容
対象読者 業務アプリやツールに自前の診断ログを組み込む開発者。ログ基盤の専任担当がいない小さなチームを想定しています
設計の適用範囲 言語には依存しません。ファイルへ追記できる環境なら、C# でも C++ でも判断は同じです
コード例 C# 12 / .NET 8PowerShell 7 で示します。他の言語でも、同じ順序で同じ判断をすれば置き換えられます
対象とするログ アプリケーション障害の切り分けに使う診断ログ
対象外 監査証跡、分散トレーシング、メトリクス基盤、クラウド集約

この記事で使う用語

設計の話に入る前に、後の章で説明なしに出てくる言葉をまとめておきます。

用語 意味
JSON Lines.jsonl 1 行に 1 個の JSON 値を書き、改行 (\n) で区切るテキスト形式。文字コードは UTF-8 で、BOM を付けてはいけないと決められています1
構造化 fields message の文章とは別に、検索したい値をキーと値の組で持たせる入れ物。{"file":"orders.csv","row":128} のような形です
single writer ファイルへ実際に書き込むのは 1 か所(1 スレッド)だけにする設計。呼び出し側が何スレッドあっても、書き込み口を 1 本に絞ります
bounded queue 上限のあるキュー。呼び出し側はキューに積むだけで戻り、書き込みは single writer 側が行います。上限があるので、あふれたときの方針を決める必要があります
drain(ドレイン) 終了時に、キューに残っているログを最後まで書き切ること。「積んだのに落ちて消えた」を防ぐ処理です
flush(フラッシュ) メモリ上のバッファを実ファイルへ吐き出すこと。ここを通していないログは、異常終了で消えます
回転(ローテーション) ファイルが大きくなったり日付が変わったりしたときに、新しいファイルへ切り替えること
保持(リテンション) 古いログファイルを何本・何日まで残すかの上限

先に「自作しない」選択肢を確認する

冒頭に書いたとおり、既製の logging framework を使えるならそのほうが安全です。判断できるように具体名を挙げておきます。ここで足りるなら、この記事の残りは読む必要がありません。

環境 選択肢 最初から付いてくるもの
.NET Microsoft.Extensions.Logging .NET 標準の ILogger API。ログレベル(Trace から Critical)、カテゴリ、出力先を差し替えるプロバイダーの仕組み。多くの .NET SDK に暗黙の参照として入っています2
.NET Serilog 構造化イベントを前提にした診断ログ。メッセージテンプレートのパラメーターに名前を付け、その値をイベントのプロパティとして保持します3
.NET NLog 構造化ログと従来型ログの両対応。出力形式に JSON レイアウトがあり、ファイル出力は自動命名とアーカイブを持ちます4
C++ spdlog C++11 以降で使えるログライブラリ。サイズで切り替える rotating と、日付で切り替える daily のファイル出力があります5

これらを使えない事情(依存を増やせない、実行環境が限られる、既存コードの制約など)があるときに、以降の最小要件が効いてきます。

先に結論

最初の版で押さえたい要点は次のとおりです。

  • 形式は UTF-8JSON Lines にする
  • 1 レコード 1 行を崩さない
  • 必須項目は 時刻レベルカテゴリメッセージ構造化 fieldssessionIdprocessId
  • 基本は 1 プロセス 1 ファイル
  • 低負荷なら同期書き込み、多めに出るなら single writer + bounded queue
  • Error / Critical とセッション開始・終了は同期 flush する
  • 回転と保持は v1 から入れる
  • 保存先が使えないときに、黙って別の場所へ逃がさない

このくらいまで絞ると、実装と運用の両方で破綻しにくくなります。

この記事の知識マップ

この記事は障害調査用の自作ロガーについて、UTF-8のJSON Linesを形式とし、logSessionIdとprocessIdを含む必須項目・single writerとbounded queueによる書き込み設計・flush条件・回転と保持・保存失敗時の明示的な通知までを最小要件として整理します。既製のMicrosoft.Extensions.Logging・Serilog・NLog・spdlogが使える場合はそちらが優先され、自作は依存を増やせないなどの制約があるときの選択肢です。logSessionIdを起動時刻とプロセスIDだけで作るとOSによるPIDの再利用で衝突しログが混ざるため、GUIDを足す設計が必要になります。最後に、実ファイル・実スレッド・実プロセスでの結合テストと行単位の機械検証によって、自作ロガーの信頼性を確認する方法を示します。

自作ロガーの知識マップ自作ロガーがJSON Lines・logSessionId・single writer・bounded queue・flush・回転保持をどう組み合わせるか、既製フレームワーク(ILogger・Serilog・NLog・spdlog)との使い分け、結合テストでの検証観点の関係を示す図利用する前提とする前提とする利用する前提とする利用する利用する利用する利用する推奨される対応用いるのは非推奨防止する原因になり得るで確認できる利用する推奨される対応推奨される対応推奨される対応推奨される対応推奨される対応推奨される対応自作ロガーロガーの結合テストJSON LinesログのsessionIdprocessId(ログ項目)single writer(単一書き込み口)bounded queue(上限付きキュー)flush(ログの同期書き出し)ログの回転(ローテーション)ログの保持(リテンション)1プロセス1ファイル複数プロセスによる同一ログファイルへの追記sessionIdの衝突別々の起動のログレコードの混在ログレコードの機械検証Microsoft.Extensions.Logging(ILogger)障害調査用の診断ログSerilogNLogspdlog保存失敗時の明示的な失敗通知

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全21件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

まず対象範囲を狭くする

自作 logger が難しくなりがちなのは、最初から何でも扱おうとするからです。診断ログ、監査ログ、性能計測、分散トレース、ユーザー行動分析を一つの仕組みでまとめようとすると、要件が一気に増えます。

今回の対象は、アプリケーション障害の切り分けに使う診断ログです。つまり、「いつ」「どの処理で」「何が起き」「そのときどんな文脈だったか」を後から追えることを優先します。これに絞るだけで、最初の設計判断はかなり楽になります。

最低限必要な要件

1. 形式は UTF-8 JSON Lines

プレーンテキストの連結でもログは残せますが、後から機械的に扱いにくくなります。逆に、最初から重い独自バイナリ形式にすると、運用時の可観測性が落ちます。

その中間として扱いやすいのが UTF-8JSON Lines です。1 行が 1 レコードであれば、テキストとしても読みやすく、後でスクリプトやツールから解析しやすくなります。途中で書き込みが切れても、壊れたのがどの行かを切り分けやすい点も実務向きです。

形式として決まっているのは、次の 3 点だけです。1

  • 1 行が 1 個の有効な JSON 値であること(空行は含めない)
  • 行の区切りは \n
  • 文字コードは UTF-8BOMU+FEFF)は付けてはいけない

拡張子は .jsonl が慣例です。BOM の禁止は見落としやすい割に効きます。BOM 付きで書くと 1 行目だけ他のツールでパースに失敗し、「最初の 1 行だけ壊れている」という分かりにくい形で表面化します。

2. 必須項目を最初に固定する

最低限そろえておきたい項目は次の 7 つです。

  • timestamp
  • level
  • category
  • message
  • fields
  • sessionId
  • processId

実際の 1 レコードは、たとえばこうなります(紙面の都合で折り返していますが、実ファイル上は改行なしの 1 行です)。

{"ts":"2026-04-02T01:15:03.4821567Z","level":"Error","category":"import","message":"取り込みに失敗しました","fields":{"file":"orders.csv","row":128,"reason":"date parse failed"},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}

同じセッションの前後を並べると、こう見えます。

{"ts":"2026-04-02T01:15:00.1002233Z","level":"Info","category":"startup","message":"アプリケーションを開始しました","fields":{"version":"1.4.2"},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
{"ts":"2026-04-02T01:15:03.4821567Z","level":"Error","category":"import","message":"取り込みに失敗しました","fields":{"file":"orders.csv","row":128,"reason":"date parse failed"},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
{"ts":"2026-04-02T01:15:03.9900011Z","level":"Info","category":"shutdown","message":"アプリケーションを終了します","fields":{"exitCode":1},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}

キー名は短くて構いませんが、一度決めたら変えないことのほうが重要です。途中で tstimestamp が混ざると、後から書く解析スクリプトが一気に面倒になります。

message だけの文字列ログにすると、あとから検索条件が増えたときに困ります。逆に項目を増やしすぎると、呼び出し側の負担が急に上がります。最初はこのくらいに固定し、追加は本当に必要になってから検討するのが安全です。

sessionId に何を入れるか

必須項目に挙げている以上、sessionId が何の単位なのかを決めておく必要があります。この記事では、プロセスの 1 回の起動を 1 セッションとします。ユーザーのログオンセッションでも、業務上の「取引」でもありません。

この定義にすると、次のことができます。

  • 1 回の起動で出たログだけをまとめて取り出せる
  • 回転でファイルが分かれても、同じ起動のログを後からつなげられる
  • 同じ端末で朝と夕方に起きた 2 件の障害を、混ぜずに切り分けられる

採番は、プロセス起動時に 1 回だけ決めて、そのプロセスが終わるまで使い回します。方法は次のどちらかで十分です。

方法 向いている場面
起動時刻 + プロセス ID + GUID 20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d 既定はこれ。先頭が人に読め、後半で衝突しない
UUIDGUID)だけ 9f0a1c72-3b58-4f2a-9a2e-6e7c1f0d55b1 複数端末のログを後から 1 か所に集める。目で読む必要がない

起動時刻 + プロセス ID だけで済ませないでください。processId は OS が再利用します。クラッシュループで同じ秒のうちに起動し直したときや、ローカル時刻が巻き戻ったときに、前回とまったく同じ sessionId になり得ます。ファイル名にも同じ値を使う設計なら、追記モードで開いた瞬間に別々の起動のログが 1 ファイルに混ざり、「1 起動 = 1 セッション」という前提が黙って崩れます。しかも壊れ方が静かなので、後から気づけません。

processId を別項目としても持つのは、sessionId を「どの起動か」、processId を「そのときの OS 上の実体」として使い分けるためです。

3. 1 プロセス 1 ファイルを基本にする

複数プロセスから同じファイルへ追記させる設計は、見た目以上に事故要因が多くなります。排他制御、部分書き込み、回転タイミング、異常終了時の扱いが一気に難しくなるからです。

まずは 1 プロセス 1 ファイル を基本にしてください。複数プロセスをまとめたいなら、後段で集約するか、専用の集約プロセスを明示的に立てるほうが安全です。

4. 書き込み戦略は負荷で分ける

ログ量が少ない段階では、同期書き込みのほうが分かりやすく、障害調査もしやすいです。無理に非同期化すると、終了直前のログを失ったり、例外時の flush 条件が曖昧になったりします。

一方で、ログ量が多くなり同期 I/O が律速になるなら、single writer + bounded queue を採用します。つまり、呼び出し側は上限付きのキューに積むだけで戻り、ファイルへ書くのは 1 か所だけにするという形です。この考え方自体は珍しいものではなく、.NET のログ設計指針でも、遅い保存先に直接書かず、メモリ上のキューへ同期的に積んでバックグラウンドの処理で送り出す形が案内されています。2

このとき重要なのは、キューがあふれたときの方針を先に決めることです。古いログを捨てるのか、新しいログを落とすのか、警告を出すのかを曖昧にしないでください。

5. flush 条件を決める

ErrorCritical、それからセッション開始・終了のログは、同期 flush しておくと障害調査で助かります。普段の Info まで全部 flush すると遅くなるため、全部を同じ扱いにしないのが現実的です。

6. 回転と保持は v1 から入れる

回転は「後で入れればよい」と思われがちですが、運用に入ると急に困る機能です。サイズ、日次、起動ごとなど方式は何でもよいので、少なくとも「無限に増え続けない」ことと「何本残すか」が決まっている状態にしておくべきです。

7. 保存失敗時に勝手な代替保存をしない

ログ保存先が使えないときに、黙って別の場所へ書く設計は、後で調査を難しくします。運用担当が「あるはずの場所」にログがないだけで、障害対応の初動が遅れます。

保存できないなら、アプリ側の通知、イベントログ、標準エラーなど、明示的に分かる手段で失敗を表に出してください。少なくとも「どこに行ったか分からない」状態は避けるべきです。

v1 の最小構成イメージ

最初の版では、次のくらいで十分なことが多いです。

  • UTF-8 JSON Lines
  • 1 プロセス 1 ファイル
  • セッション単位のファイル名
  • サイズベースまたは起動単位の回転
  • 保持本数の上限
  • Error / Critical の同期 flush
  • 構造化 fields を受け取れる API

これ以上の機能は、実際の運用で「本当に困ったこと」が見えてから足すほうが、結果として保守しやすくなります。

v1 の書き込み部分を C# で書くとどうなるか

上の要件のうち、形式・必須項目・single writerflush 条件だけを実装すると、この程度の分量です(C# 12 / .NET 8)。回転と保持は次の段で足す前提で、あえて入れていません。

using System.Text;
using System.Text.Json;

public sealed class JsonLinesLogger : IDisposable
{
    // 日本語をそのまま出す。既定のエンコーダーは非 ASCII をすべて \uXXXX へ変換する
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
    };

    private static readonly IReadOnlyDictionary<string, object?> NoFields =
        new Dictionary<string, object?>();

    private readonly object _gate = new();      // single writer をこのロックで守る
    private readonly StreamWriter _writer;
    private readonly string _sessionId;
    private readonly int _processId = Environment.ProcessId;

    public JsonLinesLogger(string path, string sessionId)
    {
        _sessionId = sessionId;

        // JSON Lines は BOM 禁止なので、BOM を書かない UTF-8 を明示する
        _writer = new StreamWriter(
            new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read),
            new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
    }

    public void Write(
        string level,
        string category,
        string message,
        IReadOnlyDictionary<string, object?>? fields = null)
    {
        var record = new Dictionary<string, object?>
        {
            ["ts"] = DateTimeOffset.UtcNow.ToString("o"),
            ["level"] = level,
            ["category"] = category,
            ["message"] = message,
            ["fields"] = fields ?? NoFields,   // null にせず、常に 7 項目そろえる
            ["sessionId"] = _sessionId,
            ["pid"] = _processId,
        };

        // 改行を含む message が来ても 1 行が壊れないよう、JSON 化してから書く
        var line = JsonSerializer.Serialize(record, JsonOptions);

        lock (_gate)
        {
            _writer.WriteLine(line);
            if (level is "Error" or "Critical")
            {
                _writer.Flush();   // 重大なログだけ同期 flush する
            }
        }
    }

    public void Dispose()
    {
        lock (_gate)
        {
            _writer.Flush();   // 正常終了時に必ず書き切る
            _writer.Dispose();
        }
    }
}

呼び出し側はこうなります。sessionId は起動時に 1 回だけ決め、ファイル名にも同じ値を使っています。

// 前掲の JsonLinesLogger を使います
var startedAt = DateTimeOffset.Now;

// 時刻とPIDだけでは足りません。クラッシュループで同じ秒のうちに起動し直したときや、
// ローカル時刻が巻き戻ったときに、Windows が再利用したPIDと組み合わさって
// 前回と同じIDになり得ます。JsonLinesLogger は FileMode.Append で開くので、
// そのとき別々の起動のレコードが1つのファイルに混ざり、
// 「1起動 = 1セッション」という前提が黙って崩れます
var sessionId = $"{startedAt:yyyyMMdd-HHmmss}-{Environment.ProcessId}-{Guid.NewGuid():N}";
var logDir = @"C:\ProgramData\MyApp\logs";
Directory.CreateDirectory(logDir);

using var logger = new JsonLinesLogger(Path.Combine(logDir, $"app-{sessionId}.jsonl"), sessionId);

logger.Write("Info", "startup", "アプリケーションを開始しました",
    new Dictionary<string, object?> { ["version"] = "1.4.2" });

logger.Write("Error", "import", "取り込みに失敗しました",
    new Dictionary<string, object?> { ["file"] = "orders.csv", ["row"] = 128 });

短いコードですが、決めたことは全部入っています。

決めたこと どこで効いているか
BOM なしの UTF-8 UTF8Encoding(encoderShouldEmitUTF8Identifier: false)
日本語を \uXXXX にしない JavaScriptEncoder.UnsafeRelaxedJsonEscaping6
1 レコード 1 行 message を直接連結せず、JsonSerializer.Serialize の結果を WriteLine する
必須 7 項目を毎回入れる fields が未指定でも NoFields を入れて欠けさせない
single writer lock (_gate) の中でだけ _writer を触る
flush 条件 Error / Critical のときだけ即 Flush()、終了時は Dispose() で必ず Flush()

UnsafeRelaxedJsonEscaping< > & ' をエスケープしないため、この出力をそのまま HTML ページや script 要素へ埋め込んではいけません6 ログファイルとして読む用途に限って使ってください。

よくある NG

避けたい典型例も挙げておきます。

  • message 文字列にすべてを詰め込む
  • 複数プロセスで同じファイルを共有する
  • flush 条件を決めずに全面非同期化する
  • 回転と保持を後回しにする
  • 保存失敗時に黙って別フォルダへ逃がす
  • ネットワーク転送やローカル DB 保存まで v1 に入れる

どれも一見便利そうですが、切り分けや運用を重くしやすい項目です。

結合テストは実ファイル・実スレッド・実プロセスで考える

logger はユニットテストだけでは安心しにくい部品です。文字列整形や JSON 化だけを検証しても、実運用で問題になるのは I/O、並行性、回転、終了時 flush、権限エラーのほうだからです。

そのため、結合テストでは実ファイル、実スレッド、必要なら実プロセスを使って確認することになります。少なくとも「普段は通るが、障害時に信用できない」という状態は避けたいところです。

通しておきたい結合テスト項目

単一書き込みの健全性

  • 1 行が 1 JSON レコードになっているか
  • UTF-8 で再読できるか
  • 必須項目が毎回入っているか
  • 改行の混入で複数行に壊れていないか

同一プロセス内の並行実行

  • 複数スレッドから同時に書いてもレコードが壊れないか
  • レコード数の過不足がないか
  • queue 利用時に順序や欠落の方針が仕様どおりか

flush と終了時の挙動

  • Error / Critical が即時反映されるか
  • 正常終了時に queue 内が空になるか
  • 例外終了に近い経路でも必要な終了ログが残るか

回転と保持

  • 回転条件を満たしたら新しいファイルへ切り替わるか
  • 保持上限を超えた古いファイルが仕様どおりに削除されるか
  • 回転直前・直後でも JSON 行が壊れないか

異常系

  • 保存先ディレクトリが存在しないときの扱い
  • 書き込み権限がないときの扱い
  • ディスクフル相当で失敗したときの通知や戻り値
  • queue あふれ時の動作

複数プロセスの扱い

もし仕様が 1 プロセス 1 ファイル なら、別プロセスが同じファイルへ入ろうとしないこと自体を確認対象にできます。逆に集約プロセス方式なら、そこへの引き渡し失敗も含めて確認が必要です。

「壊れ」をどうやって検出するか

観点を並べただけでは、テストは書けません。上の項目のうち、実装で迷いやすいのは「レコードが壊れていないこと」をどう判定するかです。目視では絶対に足りないので、次の 3 つを機械的に確認します。

見るもの 判定方法 これで見つかる壊れ
行数 書き込んだ件数と、ファイルの行数が一致するか 欠落、二重書き込み、drain 漏れ
各行 全行が単独で JSON としてパースできるか 改行の混入、書き込みの割り込み、BOM 混入
各レコード 必須 7 項目が毎回そろっているか 項目の入れ忘れ、null の混入

「先頭 10 行を見て大丈夫そう」は、並行書き込みのテストではまず役に立ちません。壊れるのは決まって真ん中のどこか 1 行だからです。全行を通すことに意味があります。

PowerShell 7 で書くと、この 3 つはまとめてこう確認できます。テストの後処理から呼ぶ想定です。

# 出力されたログを全行検証する。1 か所でも壊れていたら throw して落とす
$path     = 'C:\ProgramData\MyApp\logs\app-20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d.jsonl'
$expected = 10000   # テスト側が Write を呼んだ回数

# 「名前があること」だけでは足りません。`"level": null` のようなレコードでも
# プロパティ名は存在するので、名前の有無だけの検査は通ってしまいます。
# null の混入を捕まえるには、値と型まで見る必要があります
$required = [ordered]@{
    'ts'        = { param($v) $v -is [string] -and $v -ne '' }
    'level'     = { param($v) $v -is [string] -and $v -ne '' }
    'category'  = { param($v) $v -is [string] -and $v -ne '' }
    'message'   = { param($v) $v -is [string] }   # 空文字は許すが null は許さない
    'fields'    = { param($v) $v -is [pscustomobject] }
    'sessionId' = { param($v) $v -is [string] -and $v -ne '' }
    'pid'       = { param($v) ($v -is [int] -or $v -is [long]) -and $v -gt 0 }
}

# BOM の検査は、デコードする前に生バイトで行います。
# Get-Content -Encoding utf8 は先頭の BOM を読み飛ばしてから行を返すので、
# デコード後の文字列をいくら見ても BOM の混入は捕まりません
# (5.1 なら -AsByteStream の代わりに -Encoding Byte)
$head = @(Get-Content -LiteralPath $path -AsByteStream -TotalCount 3)
if ($head.Count -ge 3 -and $head[0] -eq 0xEF -and $head[1] -eq 0xBB -and $head[2] -eq 0xBF) {
    throw 'ファイル先頭に UTF-8 BOM があります。JSON Lines として不正です'
}

$lines = @(Get-Content -LiteralPath $path -Encoding utf8)
if ($lines.Count -ne $expected) {
    throw "行数が一致しません。期待 $expected 行 / 実際 $($lines.Count) 行"
}

$lineNo = 0
foreach ($line in $lines) {
    $lineNo++

    try {
        $record = $line | ConvertFrom-Json
    }
    catch {
        throw "$lineNo 行目が JSON として読めません: $line"
    }

    $names = $record.PSObject.Properties.Name
    foreach ($name in $required.Keys) {
        if ($names -notcontains $name) {
            throw "$lineNo 行目に必須項目がありません: $name"
        }
        if (-not (& $required[$name] $record.$name)) {
            $shown = if ($null -eq $record.$name) { '(null)' } else { "'$($record.$name)'" }
            throw "$lineNo 行目の $name の値が不正です: $shown"
        }
    }
}

"OK: $($lines.Count) 行すべてが 1 行 1 レコードとして読めました"

この形にしておくと、そのまま次のテストにも流用できます。

  • 並行書き込み: 複数スレッドから n 回書き、$expectedn にして通す
  • 回転と保持: 回転後に残った全ファイルへ同じ検証をかけ、行数は合計で照合する
  • 終了時の drain: 終了処理を挟んでから検証し、積んだ件数と一致するかを見る

異常系はこの形では測れないので、別に見ます。保存先ディレクトリが無い、書き込み権限が無い、といった条件を先に作ってから logger を初期化し、例外か戻り値か通知のどれかで失敗が表に出ることを確認します。ここで「何も起きずに握りつぶされる」実装だと、運用で最も困る壊れ方になります。

v1 で最低限通したい本数

最初に全部やろうとするとテストが重くなりすぎます。v1 で最低限通したいのは、次の 6 本くらいです。

  1. 単一スレッドでの正常書き込み
  2. 複数スレッド同時書き込み
  3. Error / Critical の flush
  4. 回転と保持
  5. 保存先異常時の失敗通知
  6. 正常終了時の drain と最終 flush

この 6 本が通っているだけでも、「文字列は出たが運用で信用できない logger」からはかなり離れられます。

まとめ

自作 logger の最初の目標は、高機能化ではなく「障害時に信じられること」です。そのためには、形式を UTF-8 JSON Lines に固定し、必須項目を絞り、1 プロセス 1 ファイル を基本にし、flush・回転・保持・失敗時挙動を早めに決めておくのが有効です。

そして、その設計が本当に機能するかどうかは、実ファイル・実スレッド・実プロセスを使う結合テストで確認する必要があります。実装を大きくする前に、まずは最小構成と最低限のテストセットを先に固めると、あとから無理なく育てやすくなります。

参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

自作ロガーのログ形式は何にすべきですか?
UTF-8のJSON Linesで、1レコード1行を崩さない形式がおすすめです。プレーンテキストの連結は後から機械的に扱いにくく、独自バイナリ形式は運用時の可観測性が落ちます。JSON Linesならテキストとして読みやすく、スクリプトやツールから解析しやすく、途中で書き込みが切れても壊れた行を切り分けやすい点が実務向きです。必須項目はtimestamp、level、category、message、構造化fields、sessionId、processIdの7つに固定します。
ログの書き込みは同期と非同期のどちらにすべきですか?
負荷で分けます。ログ量が少ない段階では同期書き込みのほうが分かりやすく障害調査もしやすいです。無理に非同期化すると、終了直前のログを失ったり、例外時のflush条件が曖昧になったりします。ログ量が多く同期I/Oが律速になるなら、single writer + bounded queueを採用し、キューがあふれたときに古いログを捨てるのか新しいログを落とすのかの方針を先に決めます。Error/Criticalとセッション開始・終了のログは同期flushしておくと障害調査で助かります。
複数プロセスから同じログファイルに書いてもよいですか?
避けるべきです。複数プロセスから同じファイルへ追記させる設計は、排他制御、部分書き込み、回転タイミング、異常終了時の扱いが一気に難しくなり、見た目以上に事故要因が多くなります。基本は1プロセス1ファイルにし、複数プロセスをまとめたいなら後段で集約するか、専用の集約プロセスを明示的に立てるほうが安全です。
自作ロガーの結合テストでは何を確認すべきですか?
実ファイル・実スレッド・実プロセスで確認します。文字列整形やJSON化のユニットテストだけでは、実運用で問題になるI/O、並行性、回転、終了時flush、権限エラーを拾えません。v1で最低限通したいのは6本で、単一スレッドでの正常書き込み、複数スレッド同時書き込み、Error/Criticalのflush、回転と保持、保存先異常時の失敗通知、正常終了時のdrainと最終flushです。この6本が通っていれば、運用で信用できないロガーからはかなり離れられます。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る