Ccmmutty logo
Commutty IT
0 pv13 min read

【C#】ログファイルの集計ツールを作る(※NLogやSerilog)

https://cdn.magicode.io/media/notebox/7367af02-5ebd-4ba2-93c3-95cf50e17183.jpeg
本記事は、C# でNLog や Serilog で生成されたログを解析する方法について記載。 自身のメモをまとめたものなので利用時は上手いこと変えて使うと良。

目次

1. ログファイル解析の重要性

そもそもログファイルは、システム運用やエラー追跡における重要な情報源です。 しかし、膨大なログデータを手動で分析するのは非効率。 自動化ツールを使えば、以下のようなメリットがある。
  • エラーや警告の迅速な抽出
  • パフォーマンス問題の特定
  • メトリクスの可視化

2. 環境設定と準備

使用技術

  • NLog: シンプルで柔軟なログライブラリ。
  • Serilog: 構造化ログが得意なログライブラリ。
  • C# (.NET 6 または 7): ツール構築の言語。

必要なパッケージ

以下の NuGet パッケージをインストールします。
dotnet add package NLog
dotnet add package NLog.Config
dotnet add package Serilog
dotnet add package Serilog.Sinks.File

3. サンプルログの構造

サンプルメッセージは適当に生成AIに作ってもらいました。
NLog のサンプルログ
2024-11-30 12:45:00.123 INFO  User logged in: user1  
2024-11-30 12:46:15.567 WARN  Disk space low: C:\ (10%)  
2024-11-30 12:48:30.891 ERROR Application crashed: NullReferenceException
Serilog のサンプルログ
2024-11-30T12:45:00.123+09:00 [Information] User logged in: user1  
2024-11-30T12:46:15.567+09:00 [Warning] Disk space low: C:\ (10%)  
2024-11-30T12:48:30.891+09:00 [Error] Application crashed: NullReferenceException

4. ログパースツールの実装

ツールの要件 ログファイルを読み込む。 各行を分割して日時、ログレベル、メッセージを抽出。 フィルタリングや集計が可能なデータ構造を生成。
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;

public class LogParser
{
    public IEnumerable<LogEntry> ParseLogFile(string filePath)
    {
        var logEntries = new List<LogEntry>();
        var logPattern = @"(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}\.\d+).*?\s(INFO|WARN|ERROR|[Ii]nformation|[Ww]arning|[Ee]rror)\s(.*)";
        
        foreach (var line in File.ReadLines(filePath))
        {
            var match = Regex.Match(line, logPattern);
            if (match.Success)
            {
                logEntries.Add(new LogEntry
                {
                    Timestamp = DateTime.Parse(match.Groups[1].Value),
                    Level = match.Groups[2].Value,
                    Message = match.Groups[3].Value
                });
            }
        }

        return logEntries;
    }
}

public class LogEntry
{
    public DateTime Timestamp { get; set; }
    public string Level { get; set; }
    public string Message { get; set; }
}

5. 追加でフィルタリングと集計を付ける

エラーのログを抽出

var logParser = new LogParser();
var logs = logParser.ParseLogFile("logfile.txt");
foreach (var log in logs.Where(log => log.Level.Contains("ERROR", StringComparison.OrdinalIgnoreCase)))
{
    Console.WriteLine($"{log.Timestamp}: {log.Message}");
}

ログレベル別の件数を集計

var levelCounts = logs.GroupBy(log => log.Level)
                      .Select(group => new { Level = group.Key, Count = group.Count() });

foreach (var level in levelCounts)
{
    Console.WriteLine($"{level.Level}: {level.Count}");
}
1ファイルで実行できるように同じファイルにMainメソッドに定義したものが下記。 検証用に色々試したのでこうなっているが基本は別途ファイルにまとめると可視性がよき。
とくに今回はコンソールに結果を出しているが エクセルなど集計結果を別に出したり別テキストにしたりする方がありそうなので ログ関連、ログパース関連、結果集計関連、ファイル出力関連、設定ファイル読み取り なんかで分けると良いと思います。
今回はだるいのでそのまま1ファイルでやりました。
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

public class LogParser
{
    public IEnumerable<LogEntry> ParseLogFile(string filePath)
    {
        var logEntries = new List<LogEntry>();
        var logPattern = @"(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}\.\d+).*?\s(INFO|WARN|ERROR|[Ii]nformation|[Ww]arning|[Ee]rror)\s(.*)";

        foreach (var line in File.ReadLines(filePath))
        {
            var match = Regex.Match(line, logPattern);
            if (match.Success)
            {
                logEntries.Add(new LogEntry
                {
                    Timestamp = DateTime.Parse(match.Groups[1].Value),
                    Level = match.Groups[2].Value,
                    Message = match.Groups[3].Value
                });
            }
        }

        return logEntries;
    }
}

public class LogEntry
{
    public DateTime Timestamp { get; set; }
    public string Level { get; set; }
    public string Message { get; set; }
}

public class Program
{
    public static void Main(string[] args)
    {
        // サンプルのログファイルパス (実際のパスに置き換えてください)
        string logFilePath = "D:\\sample_log.txt";

        // サンプルログファイルの作成 (実際には存在するログファイルを使用)
        CreateSampleLogFile(logFilePath);

        // ログパーサーを初期化
        var logParser = new LogParser();

        // ログファイルを解析
        var logs = logParser.ParseLogFile(logFilePath);

        // 結果を表示
        Console.WriteLine("Log抽出結果:");
        foreach (var log in logs)
        {
            Console.WriteLine($"{log.Timestamp:yyyy-MM-dd HH:mm:ss} [{log.Level}] {log.Message}");
        }

        // エラーの件数を集計
        var errorCount = logs.Count(log => log.Level.Contains("ERROR", StringComparison.OrdinalIgnoreCase));
        Console.WriteLine($"\nエラー合計: {errorCount}");
    }

    private static void CreateSampleLogFile(string filePath)
    {
        var sampleLogs = new[]
        {
            "2024-11-30 12:45:00.123 INFO  User logged in: user1",
            "2024-11-30 12:46:15.567 WARN  Disk space low: C:\\ (10%)",
            "2024-11-30 12:48:30.891 ERROR Application crashed: NullReferenceException"
        };

        File.WriteAllLines(filePath, sampleLogs);
    }
}
実行結果はこんな感じ。 大量のログが出力されるものや開発、デバッグ中にログ出力入れたりして 動作を確認するのに結構便利。 今回はNLog や Serilogですが、それぞれのログ形式に合わせて似たように作ってもらえたら 何とかなるかも
私はDBのSQLログを分割したりするのに似たようなものを作りました。 バッチ処理なんかの終盤を確認したいときや非同期、並列処理なんかのログを見るのは クソだるいのでこういのがあると楽ですね
Log抽出結果:
2024-11-30 12:45:00 [INFO]  User logged in: user1
2024-11-30 12:46:15 [WARN]  Disk space low: C:\ (10%)
2024-11-30 12:48:30 [ERROR] Application crashed: NullReferenceException

エラー合計: 1

ログ内容を合わせて出力する

ちなみに集計だけでなくメッセージをまとめて出すときは下記の様にすればいけます。 ちょっとファイルパスをハードコーディングは使う上でだるかったので 下記はappsettings.jsonに出してます。
特にJsonにした意味はないし .configとかの方がこのくらいなら楽なので適宜変えてください
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;

public class LogParser
{
    public IEnumerable<LogEntry> ParseLogFile(string filePath)
    {
        var logEntries = new List<LogEntry>();
        var logPattern = @"(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}\.\d+).*?\s(INFO|WARN|ERROR|[Ii]nformation|[Ww]arning|[Ee]rror)\s(.*)";

        foreach (var line in File.ReadLines(filePath))
        {
            var match = Regex.Match(line, logPattern);
            if (match.Success)
            {
                logEntries.Add(new LogEntry
                {
                    Timestamp = DateTime.Parse(match.Groups[1].Value),
                    Level = match.Groups[2].Value,
                    Message = match.Groups[3].Value
                });
            }
        }

        return logEntries;
    }
}

public class LogEntry
{
    public DateTime Timestamp { get; set; }
    public string Level { get; set; }
    public string Message { get; set; }
}

public class Program
{
    public static void Main(string[] args)
    {
        // 設定ファイルからログファイルのパスを取得
        var configuration = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
            .Build();

        string logFilePath = configuration["LogFilePath"]; // 設定ファイルからログファイルパスを取得

        // サンプルログファイルの作成 (実際には存在するログファイルを使用)
        CreateSampleLogFile(logFilePath);

        // ログパーサーを初期化
        var logParser = new LogParser();

        // ログファイルを解析
        var logs = logParser.ParseLogFile(logFilePath);

        // 結果を表示
        Console.WriteLine("Log分割結果:");
        foreach (var log in logs)
        {
            Console.WriteLine($"{log.Timestamp:yyyy-MM-dd HH:mm:ss} [{log.Level}] {log.Message}");
        }

        // エラーの件数を集計
        var errorCount = logs.Count(log => log.Level.Contains("ERROR", StringComparison.OrdinalIgnoreCase));
        Console.WriteLine($"\nエラー合計: {errorCount}");

        // 特定のエラーメッセージを集計
        var errorMessages = logs
            .Where(log => log.Level.Contains("ERROR", StringComparison.OrdinalIgnoreCase))
            .GroupBy(log => log.Message)
            .Select(group => new { Message = group.Key, Count = group.Count() })
            .OrderByDescending(group => group.Count)
            .ToList();

        // 特定のエラーメッセージを表示
        Console.WriteLine("\nエラーメッセージ一覧:");
        foreach (var error in errorMessages)
        {
            Console.WriteLine($"Error: {error.Message}, Occurrences: {error.Count}");
        }
    }

    private static void CreateSampleLogFile(string filePath)
    {
        var sampleLogs = new[]
        {
            "2024-11-30 12:45:00.123 INFO  User logged in: user1",
            "2024-11-30 12:46:15.567 WARN  Disk space low: C:\\ (10%)",
            "2024-11-30 12:48:30.891 ERROR Application crashed: NullReferenceException",
            "2024-11-30 12:50:00.123 ERROR Application crashed: NullReferenceException",
            "2024-11-30 12:52:10.456 ERROR Database connection failed",
            "2024-11-30 12:53:20.789 ERROR Database connection failed"
        };

        File.WriteAllLines(filePath, sampleLogs);
    }
}
実行結果
Log分割結果:
2024-11-30 12:45:00 [INFO]  User logged in: user1
2024-11-30 12:46:15 [WARN]  Disk space low: C:\ (10%)
2024-11-30 12:48:30 [ERROR] Application crashed: NullReferenceException
2024-11-30 12:50:00 [ERROR] Application crashed: NullReferenceException
2024-11-30 12:52:10 [ERROR] Database connection failed
2024-11-30 12:53:20 [ERROR] Database connection failed

エラー合計: 4

エラーメッセージ一覧:
Error: Application crashed: NullReferenceException, Occurrences: 2
Error: Database connection failed, Occurrences: 2

Discussion

コメントにはログインが必要です。