Structured Logging(結構化日誌)
是什麼?
Structured Logging 是以結構化資料格式(通常是 JSON)記錄日誌的實踐。每筆日誌不再是一行純文字,而是包含明確欄位(timestamp、level、message、context)的資料物件。
ℹ️非結構化 vs 結構化
非結構化:2024-01-15 10:30:00 ERROR Failed to process order 12345 for customer C001。結構化:{"timestamp":"2024-01-15T10:30:00Z","level":"error","message":"Failed to process order","orderId":"12345","customerId":"C001"}。結構化的每個欄位都可以獨立查詢和聚合。
核心觀念
結構化日誌的關鍵欄位
| 欄位 | 說明 | 範例 |
|------|------|------|
| timestamp | 事件發生時間(ISO 8601) | 2024-01-15T10:30:00.123Z |
| level | 日誌等級 | info、warn、error |
| message | 人類可讀的描述 | Order placed successfully |
| service | 服務名稱 | order-service |
| traceId | 分散式追蹤 ID | abc123def456 |
| userId / customerId | 業務上下文 | C001 |
| duration | 操作耗時(毫秒) | 150 |
| error | 錯誤資訊(stack trace) | NullReferenceException... |
日誌等級使用原則
| 等級 | 使用時機 | 生產環境 | |------|----------|---------| | DEBUG | 開發時的詳細資訊 | 關閉(按需開啟) | | INFO | 重要業務事件(訂單建立、使用者登入) | 開啟 | | WARN | 非預期但可處理的狀況(重試成功、快取 miss) | 開啟 | | ERROR | 操作失敗且影響使用者 | 開啟 + 告警 | | FATAL | 系統無法繼續運行 | 開啟 + 立即告警 |
Correlation ID 的重要性
在微服務中,一個使用者請求可能經過 5-10 個服務。每個服務都產生自己的 log,如何將它們串連起來?答案是 Correlation ID(或 Trace ID)— 在請求進入系統時產生一個唯一 ID,所有服務的 log 都帶上這個 ID。
常見誤區
⚠️常犯錯誤
- 在 log 中記錄敏感資訊(密碼、信用卡號、個資)
- 所有 log 都用 INFO 等級(失去了分級篩選的意義)
- log message 中用字串拼接而非結構化欄位(無法針對欄位查詢)
- 在高頻路徑(每次迴圈迭代)記錄 log(嚴重影響效能)
執行流程
選擇日誌框架
C# 用 Serilog、Node.js 用 Pino/Winston、Python 用 structlog
定義日誌規範
統一欄位命名、等級使用原則、敏感資訊遮蔽規則
加入 Enrichment
自動加入 traceId、serviceName、environment 等上下文
設定 Sink/Transport
設定日誌輸出目標(Console、File、Elasticsearch)
查詢與分析
用 Kibana/Grafana Loki 查詢和建立 Dashboard
流程解讀:結構化日誌的建立從選擇框架開始。不同語言有不同的最佳選擇。定義團隊統一的日誌規範確保所有服務的 log 格式一致,方便跨服務查詢。Enrichment 自動為每筆 log 加入上下文資訊,減少開發者的重複工作。Sink 設定決定 log 要送到哪裡。最終透過查詢工具進行分析和除錯。
程式碼範例
C# 版本
// Serilog 設定
using Serilog;
using Serilog.Formatting.Json;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.WithProperty("Service", "order-service")
.Enrich.WithProperty("Environment", "production")
.Enrich.FromLogContext()
.WriteTo.Console(new JsonFormatter())
.WriteTo.Seq("http://seq:5341") // 結構化日誌伺服器
.CreateLogger();
// 使用(Message Template 語法)
Log.Information("Order {OrderId} placed by {CustomerId}, total: {Total}",
order.Id, order.CustomerId, order.Total);
// 輸出 JSON:
// {"Timestamp":"2024-01-15T10:30:00Z","Level":"Information",
// "Message":"Order ORD-001 placed by C001, total: 1500",
// "Properties":{"OrderId":"ORD-001","CustomerId":"C001","Total":1500,
// "Service":"order-service","Environment":"production"}}
// 敏感資訊遮蔽
Log.Information("User {UserId} logged in from {IpAddress}",
userId, MaskIp(ipAddress));
// Middleware 自動加入 Correlation ID
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-ID"]
.FirstOrDefault() ?? Guid.NewGuid().ToString();
using (LogContext.PushProperty("CorrelationId", correlationId))
{
context.Response.Headers["X-Correlation-ID"] = correlationId;
await next();
}
});TypeScript 版本
// Pino — 高效能結構化日誌
import pino from "pino";
const logger = pino({
level: "info",
base: {
service: "order-service",
environment: process.env.NODE_ENV,
},
redact: ["req.headers.authorization", "user.password", "creditCard"],
timestamp: pino.stdTimeFunctions.isoTime,
});
// 使用
logger.info(
{ orderId: "ORD-001", customerId: "C001", total: 1500 },
"Order placed successfully"
);
// 輸出:
// {"level":"info","time":"2024-01-15T10:30:00.123Z",
// "service":"order-service","orderId":"ORD-001",
// "customerId":"C001","total":1500,
// "msg":"Order placed successfully"}
// 子 logger(自動帶上 context)
const requestLogger = logger.child({ correlationId: req.headers["x-correlation-id"] });
requestLogger.info({ userId: "U001" }, "User authenticated");
// Express middleware
import { randomUUID } from "crypto";
app.use((req, res, next) => {
const correlationId = req.headers["x-correlation-id"] as string || randomUUID();
req.log = logger.child({ correlationId });
res.setHeader("x-correlation-id", correlationId);
next();
});Python 版本
import structlog
# structlog 設定
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.processors.JSONRenderer(),
],
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
)
logger = structlog.get_logger(service="order-service")
# 使用
logger.info("order_placed",
order_id="ORD-001",
customer_id="C001",
total=1500)
# 輸出:
# {"event":"order_placed","timestamp":"2024-01-15T10:30:00Z",
# "level":"info","service":"order-service",
# "order_id":"ORD-001","customer_id":"C001","total":1500}
# 帶上下文的 logger
log = logger.bind(correlation_id=request.headers.get("X-Correlation-ID"))
log.info("user_authenticated", user_id="U001")
# 敏感資訊遮蔽
def mask_pii(_, __, event_dict):
if "email" in event_dict:
email = event_dict["email"]
event_dict["email"] = email[0] + "***@" + email.split("@")[1]
return event_dict
structlog.configure(
processors=[mask_pii, structlog.processors.JSONRenderer()]
)結構圖
圖中 Application Code 透過 Logger 記錄事件。Logger 經過 Enricher 自動加入 traceId、service name 等上下文。開發環境輸出到 Console,生產環境送到 ELK 或 Loki。最終透過 Kibana 或 Grafana 進行查詢、視覺化和告警。
面試常見問題
Q: 結構化日誌比非結構化日誌好在哪裡?
A: 結構化日誌的每個欄位都是獨立的 key-value,可以精確查詢(如 orderId: ORD-001)、聚合統計(如「過去一小時有多少 ERROR 來自 order-service」)、建立 Dashboard。非結構化日誌只能用 regex 或全文搜尋,效率低且容易漏掉。
Q: 如何處理日誌中的敏感資訊?
A: 在 Logger 層面設定 redaction(遮蔽)規則,自動將敏感欄位替換為 [REDACTED] 或部分遮蔽。常見需要遮蔽的欄位:密碼、token、信用卡號、身分證號、email(部分遮蔽)。在日誌框架中設定 redact 比在業務程式碼中手動處理更可靠。
Q: 高流量系統如何控制日誌的量和成本?
A: 設定合理的日誌等級(production 只記錄 INFO 以上)。對高頻事件使用 sampling(每 100 次記錄 1 次)。按保留期限分級(ERROR 保留 90 天、INFO 保留 30 天、DEBUG 保留 7 天)。使用高壓縮比的儲存(Loki 比 Elasticsearch 便宜很多)。
理解測驗
🤔 結構化日誌的核心優勢是什麼?
🤔 Correlation ID 的作用是什麼?
🤔 以下哪個日誌等級的使用方式是正確的?
重點整理
💡一句話記住
Structured Logging = JSON 格式 + 明確欄位 + 自動上下文。 口訣:「格式要 JSON,欄位要統一,敏感要遮蔽」
| 概念 | 說明 | |------|------| | 結構化日誌 | 以 JSON 等格式記錄,每個欄位可獨立查詢 | | Correlation ID | 跨服務串連同一請求的日誌 | | Enrichment | 自動加入 traceId、service 等上下文 | | Redaction | 遮蔽密碼、信用卡等敏感資訊 | | 核心原則 | 統一格式、分級記錄、保護隱私 |