6.5 如何新增結構化記錄欄位
新增結構化記錄欄位的核心規則是先判斷這筆資訊是給工程師除錯、給系統重播,還是給使用者查詢。不同用途對應不同記錄邊界,資料應依用途進入 log、event log 或 repository。
本章目標
學完本章後,你將能夠:
- 分辨 structured log、domain event log 與 state repository
- 設計穩定的 log 欄位名稱
- 判斷哪些資料不應寫進 log
- 用
EventLog.Append表達事件記錄邊界 - 測試穩定欄位,而不是測自由文字
【觀察】先判斷記錄用途
記錄邊界的核心問題是資料要服務誰。工程師除錯、系統重播、使用者查詢是三種不同用途,對應三種不同儲存與格式責任。
| 記錄類型 | 用途 | 範例 |
|---|---|---|
| structured log | 操作診斷、除錯、聚合查詢 | queue full、event rejected、worker failed |
| domain event log | 記錄已發生事實、audit、replay | notification.created、job.failed |
| state repository | 查詢目前狀態或投影 | job current status、notification summary |
structured log 服務操作診斷,event log 保存 normalized fact,state repository 回答目前狀態。先分清楚用途,才知道欄位該放哪裡。這個用途判斷比選擇哪個 logging package 更關鍵 — 工具決定怎麼寫,用途決定寫什麼、放哪裡。
【判讀】structured log 是操作訊號
structured log 的核心用途是讓工程師知道系統正在發生什麼,並且能用欄位查詢。它應該記錄操作訊號,而不是完整業務資料。
1logger.Info(
2 "event accepted",
3 "layer", "adapter",
4 "event_type", string(event.Type),
5 "event_id", event.ID,
6 "subject_id", event.SubjectID,
7 "correlation_id", event.CorrelationID,
8)message 給人讀,欄位給查詢工具使用。若未來要查某種事件是否大量進入系統,event_type 欄位比文字搜尋更可靠。
常見 log 欄位可以先定義成 helper,避免不同地方拼出不同名稱:
1func LogAttrsForEvent(event DomainEvent) []any {
2 return []any{
3 "event_id", event.ID,
4 "event_type", string(event.Type),
5 "subject_kind", string(event.SubjectKind),
6 "subject_id", event.SubjectID,
7 "correlation_id", event.CorrelationID,
8 "schema_version", event.SchemaVersion,
9 }
10}使用時可以展開欄位:
1logger.Info("event accepted", LogAttrsForEvent(event)...)這個 helper 保護的是 log schema。欄位名稱穩定,查詢與 dashboard 才能穩定。
【策略】reason 欄位要像 enum
reason 的核心語意是可聚合的原因分類。它應使用小集合穩定值;完整錯誤訊息則放在 error 欄位協助診斷。
1const (
2 ReasonInvalidPayload = "invalid_payload"
3 ReasonQueueFull = "queue_full"
4 ReasonDuplicateEvent = "duplicate_event"
5 ReasonTimeout = "timeout"
6)記錄拒絕事件時:
1logger.Warn(
2 "event rejected",
3 "layer", "adapter",
4 "reason", ReasonInvalidPayload,
5 "event_type", string(event.Type),
6 "error", err,
7)reason 用來統計,error 用來診斷,message 用來讓人快速理解。這三者不要混成一個大字串。
【判讀】event log 記錄 normalized fact
domain event log 的核心責任是保存已正規化的 domain event。它記錄的是系統承認的事實;raw request、debug log 與目前狀態分別屬於不同記錄邊界。
先定義 port:
1type EventLog interface {
2 Append(ctx context.Context, event DomainEvent) error
3}memory implementation 可以先這樣寫:
1type InMemoryEventLog struct {
2 mu sync.Mutex
3 events []DomainEvent
4}
5
6func NewInMemoryEventLog() *InMemoryEventLog {
7 return &InMemoryEventLog{}
8}
9
10func (l *InMemoryEventLog) Append(ctx context.Context, event DomainEvent) error {
11 l.mu.Lock()
12 defer l.mu.Unlock()
13
14 l.events = append(l.events, cloneDomainEvent(event))
15 return nil
16}event log 應該保存 DomainEvent envelope 中的穩定欄位,例如 event ID、type、subject、schema version、occurred/received time。它不需要保存 adapter 的 raw input,除非你已經明確設計 raw audit log。
【執行】event log 要保護 copy boundary
event log 的核心資料也是內部狀態。若 event 包含 slice、map 或 json.RawMessage,append 與讀取時都要避免外部修改內部資料。
1func cloneDomainEvent(event DomainEvent) DomainEvent {
2 cloned := event
3 if event.Payload != nil {
4 cloned.Payload = append(json.RawMessage(nil), event.Payload...)
5 }
6 return cloned
7}若要提供查詢方法,也要回傳複製資料:
1func (l *InMemoryEventLog) List() []DomainEvent {
2 l.mu.Lock()
3 defer l.mu.Unlock()
4
5 result := make([]DomainEvent, len(l.events))
6 for i, event := range l.events {
7 result[i] = cloneDomainEvent(event)
8 }
9 return result
10}這裡展示的是教學用記錄邊界。真正 event store 還需要持久化、排序、[schema migration](/go/backend/knowledge-cards/schema-migration)、重播策略與交易語意。
【策略】state repository 保存目前狀態
state repository 的核心責任是回答目前狀態。它可以由 event 更新,但用途不同於保存所有歷史事實的 event log。
例如:
1type JobRepository interface {
2 Apply(ctx context.Context, event DomainEvent) error
3 Get(ctx context.Context, id string) (JobProjection, bool, error)
4}event log 和 state repository 可以在 processor 中各自被呼叫:
1type RecordingEventProcessor struct {
2 eventLog EventLog
3 repository JobRepository
4 logger *slog.Logger
5}
6
7func (p *RecordingEventProcessor) Process(ctx context.Context, event DomainEvent) error {
8 if err := p.eventLog.Append(ctx, event); err != nil {
9 return fmt.Errorf("append event log: %w", err)
10 }
11
12 if err := p.repository.Apply(ctx, event); err != nil {
13 return fmt.Errorf("apply state projection: %w", err)
14 }
15
16 p.logger.Info("event processed", LogAttrsForEvent(event)...)
17 return nil
18}這段程式展示三種記錄邊界:event log 保存事實,repository 更新目前狀態,structured log 記錄操作訊號。
【判讀】記錄位置要跟錯誤發生層一致
記錄位置的核心規則是在哪一層能提供最多上下文,就在哪一層記錄。同一個錯誤通常選擇一個主要層次記錄,避免 log 被重複訊號淹沒。
常見位置:
| 發生位置 | 應記錄內容 |
|---|---|
| adapter | raw input decode/normalize 失敗 |
| router/usecase | command 被拒絕、權限不足、狀態不允許 |
| processor | event validation、dedup、projection apply 結果 |
| worker | queue full、外部來源失敗、重試結果 |
例如 adapter 解碼失敗:
1logger.Warn(
2 "callback rejected",
3 "layer", "adapter",
4 "reason", ReasonInvalidPayload,
5 "payload_bytes", len(body),
6)這裡記錄 payload 大小即可診斷資料是否異常;完整 payload 可能包含敏感資料或過大內容。
【策略】敏感資料預設不進 log
敏感資料邊界的核心規則是 log 會被保存、轉發與搜尋,所以 token、password、完整 payload、完整個資應排除在 log 之外。
可以記錄:
- ID 或 opaque identifier
- payload byte length
- schema version
- 欄位是否存在
- hash 或 checksum
不應記錄:
- password
- access token
- cookie
- 完整 request body
- 完整 personal data
若需要追蹤同一筆資料,可以記錄安全識別碼:
1logger.Debug(
2 "payload received",
3 "payload_bytes", len(body),
4 "payload_sha256", sha256Hex(body),
5)debug log 也需要遵守同樣規則;只要可能被集中收集,就要先控制敏感資料。
【執行】log helper 測試只測穩定欄位
log helper 測試的核心目標是保護欄位名稱與值。log message 文案是給人讀的內容,通常保留調整空間。
1func TestLogAttrsForEvent(t *testing.T) {
2 event := DomainEvent{
3 ID: "evt_1",
4 Type: EventNotificationCreated,
5 SubjectKind: SubjectNotification,
6 SubjectID: "ntf_1",
7 CorrelationID: "corr_1",
8 SchemaVersion: 1,
9 }
10
11 attrs := LogAttrsForEvent(event)
12 got := attrsToMap(attrs)
13
14 if got["event_id"] != "evt_1" {
15 t.Fatalf("event_id = %v, want evt_1", got["event_id"])
16 }
17 if got["event_type"] != string(EventNotificationCreated) {
18 t.Fatalf("event_type = %v, want %s", got["event_type"], EventNotificationCreated)
19 }
20}測試輔助函式可以把 key-value slice 轉成 map:
1func attrsToMap(attrs []any) map[string]any {
2 result := make(map[string]any)
3 for i := 0; i+1 < len(attrs); i += 2 {
4 key, ok := attrs[i].(string)
5 if !ok {
6 continue
7 }
8 result[key] = attrs[i+1]
9 }
10 return result
11}這個測試直接檢查 helper 輸出,不需要真的寫 log 或解析 logger output。
【執行】event log 測試要保護 append 與 copy
event log 測試的核心目標是確認事件被 append,且外部無法透過原始 payload 或回傳值修改內部紀錄。
1func TestInMemoryEventLogAppendCopiesPayload(t *testing.T) {
2 log := NewInMemoryEventLog()
3 payload := json.RawMessage(`{"topic":"deployments"}`)
4
5 event := DomainEvent{
6 ID: "evt_1",
7 Type: EventNotificationCreated,
8 SubjectKind: SubjectNotification,
9 SubjectID: "ntf_1",
10 OccurredAt: time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC),
11 ReceivedAt: time.Date(2026, 4, 22, 10, 1, 0, 0, time.UTC),
12 SchemaVersion: 1,
13 Payload: payload,
14 }
15
16 if err := log.Append(context.Background(), event); err != nil {
17 t.Fatalf("append event: %v", err)
18 }
19
20 payload[0] = '['
21
22 events := log.List()
23 if string(events[0].Payload) != `{"topic":"deployments"}` {
24 t.Fatalf("payload was modified through original slice")
25 }
26}json.RawMessage 本質是 []byte,所以需要 copy。這類細節很容易被忽略,測試可以把邊界固定下來。
實作檢查清單
新增結構化記錄欄位時,可以依序檢查:
- 這筆資料是給除錯、重播,還是查詢
- structured log 是否只保存操作訊號與安全欄位
- event log 是否保存 normalized domain event
- state repository 是否只保存目前 projection
- log 欄位名稱是否穩定
reason是否是小集合分類- 是否避免完整 payload 與敏感資料
- event log 是否保護 copy boundary
- 測試是否檢查穩定欄位,而不是自由文字
設計檢查
檢查一:log 服務操作診斷
log 是操作診斷訊號,不是穩定查詢 API。需要使用者查詢的目前狀態,應該進 repository 或 read model。
檢查二:event log 保存 normalized fact
event log 記錄的是 normalized fact。若把暫時性錯誤、debug 訊息與 raw payload 全塞進 event log,重播與 audit 會變得不可信。
檢查三:欄位名稱維持一致
event_id、eventID、id 混用會讓查詢失效。欄位 schema 要像 API 一樣維持穩定。
檢查四:完整 payload 需要明確策略
完整 payload 可能包含敏感資料,也可能非常大。除非有明確安全與保存策略,否則只記錄大小、hash、ID 與必要欄位。
本章不處理
本章先處理 log、event log 與 repository 的分工;集中式 log 平台與可重播事件系統,會在下列章節再往外延伸:
- Go 進階:Durable queue、outbox 與 idempotency
- Go 進階:Observability pipeline、metrics 與 tracing
- Backend:可觀測性平台
和 Go 教材的關係
這一章承接的是 event log、state repository 與 log schema;如果你要先回看語言教材,可以讀: