.NET 6.0 如何使用 Serilog 對應用程式事件進行結構化紀錄 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

.NET 6.0 如何使用 Serilog 對應用程式事件進行結構化紀錄

Serilog 是我在撰寫 .NET 應用程式時唯一推薦的 Logging 工具,基本上 Serilog 的 NuGet 套件支援 .NET Framework 4.5+ 與 .NET Core 1.0+ 版本,相容性非常高。這篇文章我打算說明在 .NET 6 完整設定 Serilog 的過程,以及在不同情境下的注意事項。

以下我將會以 .NET 6 為主進行設定相關說明。

設定 Console 應用程式使用 Serilog 進行記錄

  1. 建立 .NET 6.0 的 Console 專案

    dotnet new webapi -n serilogdemo
    cd serilogdemo
    
  2. 安裝 Serilog 相關套件

    要使用 Serilog 進行紀錄 (Logging) 通常需要一個 Serilog 主要套件,搭配一個 Sinks 將紀錄「流」到指定的目的地。如果只是單純將 Log 輸出到 Console 的話,就只要安裝以下兩個套件即可:

    dotnet add package Serilog
    dotnet add package Serilog.Sinks.Console
    

    不過實務上你可能還會加裝一些額外的 Serilog 相關套件,需要的時候是可以隨時使用:

    dotnet add package Serilog.Sinks.Debug
    dotnet add package Serilog.Sinks.File
    dotnet add package Serilog.Sinks.Seq
    dotnet add package Serilog.Sinks.MSSqlServer
    
    dotnet add package Serilog.Settings.Configuration
    dotnet add package Serilog.Extensions.Hosting
    dotnet add package Serilog.Formatting.Compact
    
    dotnet add package Serilog.Enrichers.Environment
    dotnet add package Serilog.Enrichers.Thread
    
  3. 初始化 Serilog 設定

    Serilog 有個靜態類別 Log 可用在應用程式的任何地方,因此使用之前一定要先設定好 Log.Logger 才能開始使用 Log 靜態類別底下的靜態方法!

    以下範例設定 Serilog 的最低記錄等級為 Debug 等級,這意味著 Debug, Information, Warning, Error, Fatal 等級的 Log 都會被記錄下來:

    Log.Logger = new LoggerConfiguration()
        .MinimumLevel.Information()
        .WriteTo.Console()
        .WriteTo.File("logs/myapp.txt", rollingInterval: RollingInterval.Day)
        .CreateLogger();
    

    以下是設定好之後可以使用的 Log 靜態方法,以下是內建的六種記錄等級 (Log Level) (依序排列):

    Log.Verbose("Hello");
    Log.Debug("Hello");
    Log.Information("Hello");
    Log.Warning("Hello");
    Log.Error("Hello");
    Log.Fatal("Hello");
    

    這裡有一點要特別,那就是 .NET Core 原生的 LogLevel 區分 7 個層級,分別是 Trace = 0, Debug = 1, Information = 2, Warning = 3, Error = 4, Critical = 5, None = 6 等等。

    但是 Serilog 的 LogEventLevel 只有 6 個層級,分別是 Verbose = 0, Debug = 1, Information = 2, Warning = 3, Error = 4, Fatal = 5,除了 VerboseFatal 這兩項名稱不一樣但意義相同外,Serilog 並沒有相對應的 None 這個選項。如果你真的想要設定 Serilog 的記錄等級為不要輸出 Log 的話,可以參考這篇文章進行設定。

  4. 將主程式的 Main() 方法全部用 try/catch/finally 包裹起來

    這裡最重要的是 finally 段落的 Log.CloseAndFlush(); 陳述式 (Statement),他會幫助你將應用程式意外結束時的最後幾筆 Log 紀錄確實寫入 Sinks 中!

    try
    {
        // 這裡請放你原本主程式要寫的所有程式碼!
        Log.Verbose("Hello");
        Log.Debug("Hello");
        Log.Information("Hello");
        Log.Warning("Hello");
        Log.Error("Hello");
        Log.Fatal("Hello");
    }
    catch (Exception ex)
    {
        // 紀錄你的應用程式中未被捕捉的例外 (Unhandled Exception)
        Log.Error(ex, "Something went wrong");
    }
    finally
    {
        Log.CloseAndFlush(); // 非常重要的一段!
    }
    
  5. 完整範例程式

    using Serilog;
    
    Log.Logger = new LoggerConfiguration()
        .MinimumLevel.Verbose()
        .WriteTo.Console()
        .WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day)
        .CreateLogger();
    
    try
    {
        Log.Verbose("Hello");
        Log.Debug("Hello");
        Log.Information("Hello");
        Log.Warning("Hello");
        Log.Error("Hello");
        Log.Fatal("Hello");
    
        Console.WriteLine("Hello, World!");
    }
    catch (Exception ex)
    {
        // 紀錄你的應用程式中未被捕捉的例外 (Unhandled Exception)
        Log.Error(ex, "Something went wrong");
    }
    finally
    {
        // 將最後剩餘的 Log 寫入到 Sinks 去!
        Log.CloseAndFlush();
    }
    

    使用 dotnet run 就可以立刻看到執行結果。

  6. 安裝 .NET 相關套件

    以下是在開發 Console 應用程式專案可能也會用到的 NuGet 套件,讓你可以在程式中處理 DI, Logging 與 Configuration 等設定。

    dotnet add package Microsoft.Extensions.DependencyInjection
    
    dotnet add package Microsoft.Extensions.Logging
    
    dotnet add package Microsoft.Extensions.Configuration
    dotnet add package Microsoft.Extensions.Configuration.Json
    dotnet add package Microsoft.Extensions.Configuration.Ini
    dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables
    dotnet add package Microsoft.Extensions.Configuration.CommandLine
    

設定 ASP.NET Core 應用程式使用 Serilog 進行記錄

這裡我以 ASP.NET Core 6.0 的 Minimal API 進行示範。

  1. 建立 ASP.NET Core 6.0 的 Web API 專案

    dotnet new webapi -n serilogdemo2
    cd serilogdemo2
    
  2. 安裝 Serilog 相關套件

    要在 ASP.NET Core 6.0 使用 Serilog 進行紀錄 (Logging) 只需要安裝 Serilog.AspNetCore 套件即可,因為這個套件本身就包含了許多相依套件,安裝過程會全部自動安裝好。

    dotnet add package Serilog.AspNetCore
    
  3. 初始化 Serilog 設定

    using Serilog;
    using Serilog.Events;
    
    Log.Logger = new LoggerConfiguration()
        .MinimumLevel.Information()
        .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
        .Enrich.FromLogContext()
        .WriteTo.Console()
        .WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day)
        .CreateLogger();
    
  4. 將主程式 Program.cs 全部用 try/catch/finally 包裹起來

    try
    {
        Log.Information("Starting web host");
    
        // TODO: 將原本 Program.cs 所有程式碼搬到這裡
    
        return 0;
    }
    catch (Exception ex)
    {
        Log.Fatal(ex, "Host terminated unexpectedly");
        return 1;
    }
    finally
    {
        Log.CloseAndFlush();
    }
    
  5. 透過 IWebHostBuilder 加入 UseSerilog()

    builder.Host.UseSerilog(); // <-- 加入這一行
    
    var app = builder.Build();
    
  6. 移除 appsettings.json"Logging" 區段,並加入 "Serilog" 區段

    以下設定值與步驟 3 的 初始化 Serilog 設定 是完全一樣的:

    {
      "Serilog": {
        "MinimumLevel": {
          "Default": "Information",
          "Override": {
            "Microsoft.AspNetCore": "Warning"
          }
        },
        "Enrich": [ "FromLogContext" ],
        "WriteTo": [
          { "Name": "Console" },
          { "Name": "File", "Args": { "path":  "./logs/log-.txt", "rollingInterval": "Day" } }
        ]
      },
      "AllowedHosts": "*"
    }
    
  7. 修改 Serilog 設定,改從 Configuration 讀入組態設定

    透過以下程式你可以發現,你必須將 var builder = WebApplication.CreateBuilder(args); 移到前面來,讓你的 LoggerConfiguration 可以輕易取得 builder.Configuration 物件 (該物件有實作 IConfiguration 介面)

    using Serilog;
    
    var builder = WebApplication.CreateBuilder(args);
    
    Log.Logger = new LoggerConfiguration()
        .ReadFrom.Configuration(builder.Configuration)
        .CreateLogger();
    
  8. 加入 Request logging 要求記錄

    當你將 Microsoft.AspNetCore 記錄類別 (Log Category) 設定為 Warning 之後,預設 Serilog 不會將所有 Request 詳細資訊記錄下來。但是若你開到 Information 層級的話,每一個 Request 又會包含好幾條 Log 訊息,雖然資訊十分豐富,但是卻會讓 Log 更加顯的雜亂無章,不易閱讀。

    此時你可以使用 app.UseSerilogRequestLogging() 這個 Middleware 來整理所有與 Request 相關的紀錄,讓你在一條 Log 中就可以取得目前 Request 所有的相關資訊。

    注意:這個 Request logging 的紀錄等級為 Information 喔!

    由於 Log 的欄位很多,使用 Console Sink 會比較看不出來,改用 Serilog.Formatting.Compact 來記錄 JSON 格式的 Log 訊息會清楚很多!

    以下是 WriteTo 的設定內容:

    {
      "Serilog": {
        "MinimumLevel": {
          "Default": "Information",
          "Override": {
            "Microsoft.AspNetCore": "Warning"
          }
        },
        "Enrich": [ "FromLogContext" ],
        "WriteTo": [
          {
            "Name": "Console"
          },
          {
            "Name": "File",
            "Args": {
                "path":  "./logs/log-.json",
                "rollingInterval": "Day",
                "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact"
              }
          }
        ]
      },
      "AllowedHosts": "*"
    }
    

    以下是我發出 GET /WeatherForecast API 呼叫的 Log 內容,雖然會失去 ControllerContext 相關資訊,但你可以預設取得 RequestMethod, RequestPath, StatusCode, Elapsed 這四個屬性 (Properties):

    {
      "@t": "2021-11-29T15:42:40.1238894Z",
      "@mt": "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms",
      "@r": ["0.1917"],
      "RequestMethod": "GET",
      "RequestPath": "/WeatherForecast",
      "StatusCode": 200,
      "Elapsed": 0.1917,
      "SourceContext": "Serilog.AspNetCore.RequestLoggingMiddleware",
      "RequestId": "0HMDJ9RFLP5N0:0000000D",
      "ConnectionId": "0HMDJ9RFLP5N0"
    }
    

    如果這些擴充的屬性不夠用,還是可以新增自訂的屬性到 Log 之中,你只要注入 IDiagnosticContext 物件,就可以用 Set() 方法來寫入額外的屬性。例如你想要紀錄登入的使用者,就可以用以下程式碼增加 UserID 屬性到 Structured Log 之中:

    _diagnosticContext.Set("UserID", User.Identity?.Name);
    

    以下是完整範例:

    using Microsoft.AspNetCore.Mvc;
    using Serilog;
    
    namespace serilogdemo2.Controllers;
    
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };
    
        private readonly ILogger<WeatherForecastController> _logger;
        private readonly IDiagnosticContext _diagnosticContext;
    
        public WeatherForecastController(ILogger<WeatherForecastController> logger, IDiagnosticContext diagnosticContext)
        {
            _logger = logger;
            _diagnosticContext = diagnosticContext;
        }
    
        [HttpGet(Name = "GetWeatherForecast")]
        public IEnumerable<WeatherForecast> Get()
        {
            _diagnosticContext.Set("UserID", User.Identity?.Name);
    
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }
    

    如果你想一勞永逸的將每個 Request 都預設加上一些屬性,那麼你可以這樣註冊 Middleware:

    app.UseSerilogRequestLogging(options =>
    {
        // 如果要自訂訊息的範本格式,可以修改這裡,但修改後並不會影響結構化記錄的屬性
        options.MessageTemplate = "Handled {RequestPath}";
    
        // 預設輸出的紀錄等級為 Information,你可以在此修改記錄等級
        // options.GetLevel = (httpContext, elapsed, ex) => LogEventLevel.Debug;
    
        // 你可以從 httpContext 取得 HttpContext 下所有可以取得的資訊!
        options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
        {
            diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
            diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
            diagnosticContext.Set("UserID", httpContext.User.Identity?.Name);
        };
    });
    

相關連結