Serilog 結構化日誌指南
用途:為 .NET 應用程式提供結構化日誌(Structured Logging)能力
NuGet:
Serilog(核心)/Serilog.Sinks.Console/Serilog.Sinks.File等授權:Apache-2.0
支援:.NET 6+、.NET Framework 4.6.2+
為什麼需要結構化日誌
傳統日誌把所有資訊塞進一個字串,想找特定設備的溫度異常?只能 grep 整段文字,遇到格式不統一就沒轍:
// ❌ 傳統文字日誌:資訊埋在字串裡,無法程式化搜尋
logger.Info($"[{DateTime.Now}] Device CMP-01 temperature 85.3°C exceeded threshold");
logger.Info($"[{DateTime.Now}] Device CVD-02 temperature 72.1°C normal");
想搜尋「溫度超過 80°C 的所有設備」?得寫正規表達式解析字串,而且每次 log 格式稍有變動就要重寫。
結構化日誌 的核心思想是:日誌事件不只是一行文字,而是一個帶有具名屬性的結構化資料:
// ✅ 結構化日誌:屬性獨立保存,可搜尋、可分析
Log.Information("Device {DeviceId} temperature {Temperature}°C {Status}",
"CMP-01", 85.3, "exceeded threshold");
底層儲存的不只是字串,而是:
{
"MessageTemplate": "Device {DeviceId} temperature {Temperature}°C {Status}",
"DeviceId": "CMP-01",
"Temperature": 85.3,
"Status": "exceeded threshold",
"Timestamp": "2026-04-10T14:30:00.000+08:00",
"Level": "Information"
}
這樣就能用 SQL 或搜尋引擎直接查詢:WHERE Temperature > 80 AND DeviceId LIKE 'CMP%'。
在工業自動化場景的價值
| 場景 | 傳統日誌的痛點 | 結構化日誌的優勢 |
|---|---|---|
| 設備通訊異常追蹤 | grep 搜尋 IP、Port 混在文字中 | 直接查 PlcId = "PLC-03" |
| 效能瓶頸分析 | 手動計算回應時間散佈在各行 | 數值欄位可直接統計 avg/p99 |
| 跨工站關聯分析 | 不同格式難以 join | 共用 StationId 屬性直接關聯 |
| 審計合規(FDA Part 11) | 文字日誌容易被篡改 | 結構化寫入 DB 可加簽章 |
Serilog vs NLog vs Microsoft.Extensions.Logging
| 比較項目 | Serilog | NLog | Microsoft.Extensions.Logging (MEL) |
|---|---|---|---|
| 定位 | 結構化日誌原生支援 | 功能齊全的傳統日誌框架 | .NET 內建抽象層 |
| 設定方式 | Fluent API + JSON | XML 為主 + JSON | DI 容器設定 |
| 結構化支援 | 原生 MessageTemplate | 後來支援,但非核心設計 | 依賴底層 Provider |
| Sink / Target 生態 | 200+ Sink | 80+ Target | 依賴第三方 Provider |
| 學習曲線 | 低(API 直覺) | 中(XML 設定較多) | 低(但功能有限) |
| 推薦用法 | 當底層實作 | 遺留專案使用 | 當介面層(搭配 Serilog 底層) |
MEL ILogger<T> 當介面,Serilog 當實作。程式碼只依賴 Microsoft.Extensions.Logging.ILogger<T>,Serilog 負責底層的 Sink 輸出和 Enrichment。這樣既保持程式碼的框架獨立性,又能享受 Serilog 強大的結構化能力。
安裝
# 核心套件
dotnet add package Serilog
# 常用 Sink
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
# DI 整合(推薦)
dotnet add package Serilog.Extensions.Logging
# JSON 設定支援
dotnet add package Serilog.Settings.Configuration
# 常用 Enricher
dotnet add package Serilog.Enrichers.Thread
dotnet add package Serilog.Enrichers.Environment
核心概念
Logger 與 LogEvent
Serilog 的運作圍繞幾個核心概念:
| 概念 | 說明 |
|---|---|
| Logger | 日誌記錄器,負責接收 log 呼叫並分派給 Sink |
| LogEvent | 一筆日誌事件,包含 Timestamp、Level、MessageTemplate、Properties、Exception |
| Sink | 日誌輸出目標(Console、File、DB、遠端伺服器等) |
| Enricher | 自動為每筆 LogEvent 附加額外屬性(ThreadId、MachineName 等) |
| MessageTemplate | 日誌訊息模板,{PropertyName} 語法擷取結構化屬性 |
日誌等級
由低到高,六個等級:
| 等級 | 用途 | 工業場景範例 |
|---|---|---|
| Verbose | 最詳細的追蹤資訊 | 每筆 Modbus frame 原始 byte |
| Debug | 開發階段除錯 | PLC Register 讀取值 |
| Information | 正常運作的重要事件 | 設備連線成功、Recipe 切換 |
| Warning | 異常但系統仍可運作 | 通訊重試、溫度接近上限 |
| Error | 錯誤,功能受影響 | PLC 通訊失敗、Recipe 執行錯誤 |
| Fatal | 系統無法繼續運作 | 主控程式崩潰、資料庫無法連線 |
MessageTemplate 語法
MessageTemplate 是 Serilog 的靈魂。它看起來像字串插值,但運作方式完全不同:
// ⚠️ 字串插值($"..."):產生純文字,失去結構化能力
Log.Information($"PLC {plcId} register {address} = {value}"); // 不要這樣寫
// ✅ MessageTemplate:屬性被獨立擷取
Log.Information("PLC {PlcId} register {Address} = {Value}", plcId, address, value);
特殊運算子:
// @ 運算子:物件解構(Destructuring)— 展開物件的屬性
var status = new { Mode = "Auto", Temperature = 25.5, Pressure = 1.2 };
Log.Information("Device status: {@DeviceStatus}", status);
// 產生:{ "Mode": "Auto", "Temperature": 25.5, "Pressure": 1.2 }
// $ 運算子:強制 ToString()
Log.Information("Raw data: {$RawBytes}", byteArray);
// 產生:"System.Byte[]" 而非展開陣列
Log Pipeline
每次呼叫 Log.Information(...) 等 API 都會建立一個 LogEvent,依序通過 Enricher、Filter,最後由 Sink Routing 分派到一個或多個輸出目標:
各階段重點:
- LogEvent 是不可變資料:一旦建立後 Enricher 只會「附加」屬性,不能修改既有屬性
- Enricher 順序很重要:先註冊的 Enricher 先執行;自訂 Enricher 通常依賴前面的 Enricher 已附加的屬性
- Filter 一次篩選:
MinimumLevel是全域底線,子 Sink 可用restrictedToMinimumLevel各自加嚴 - Sink 是廣播式:通過 Filter 的 LogEvent 會送到所有設定的 Sink,並非擇一—多 Sink 同時寫入是常態
Sink 路由:依屬性分流
進階情境會依 Level 或 Property 把 LogEvent 路由到不同 Sink,例如把警報級以上的事件額外送到 Seq,正常事件只進檔案:
對應的 LoggerConfiguration 寫法(簡化版):
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.WithThreadId()
.Enrich.WithMachineName()
.WriteTo.Console()
.WriteTo.File("logs/equipment-.log", rollingInterval: RollingInterval.Day)
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(le => le.Level >= LogEventLevel.Warning)
.WriteTo.Seq("http://seq.local"))
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(le => le.Properties.ContainsKey("HasAuditTag"))
.WriteTo.File("logs/audit-.log", rollingInterval: RollingInterval.Day))
.CreateLogger();
子 Logger(WriteTo.Logger(lc => ...))是路由策略的關鍵:每個子 Logger 都有自己的 Filter,只有通過的 LogEvent 才會進入該分支的 Sink。
最小範例
using Serilog;
// 建立 Logger
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.WriteTo.File("logs/equipment-.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
// 使用
Log.Information("Equipment control system starting");
Log.Debug("Connecting to PLC {PlcId} at {IpAddress}:{Port}", "PLC-01", "192.168.1.10", 502);
try
{
// 設備操作...
Log.Information("PLC {PlcId} connected successfully", "PLC-01");
}
catch (Exception ex)
{
Log.Error(ex, "Failed to connect PLC {PlcId}", "PLC-01");
}
finally
{
// 確保所有日誌都寫入完成
await Log.CloseAndFlushAsync();
}
延伸閱讀
- Sink 與設定 — 各種 Sink 介紹、Fluent API / JSON 設定、Enricher
- 公司場景 Pattern — 設備通訊日誌、審計日誌、效能計量、DI 整合
- Polly 韌性策略 — 搭配 Serilog 記錄重試/熔斷事件