結論先說:metrics 回答「有沒有問題」,logs 回答「發生了什麼」,traces 回答「這次請求卡在哪」;把三者當成可以互相取代的同一份資料,最後三樣都不夠用。
/ask 的 availability 突然下降,值班工程師打開 Grafana,畫面上同時有 Prometheus 的圖表、Loki 的 log stream 和 Tempo 的 trace 搜尋框。三個都能開,但先點哪一個,決定排查要花五分鐘還是五十分鐘。
這不是誇張的說法。想像兩種排查路徑的落差:路徑 A 先開 Tempo,輸入時間範圍搜出十幾條 trace,逐條點開比對哪裡不對勁,過程中不確定這十幾條是不是能代表整體現象;路徑 B 先看 Prometheus 的 timeout ratio 圖表,十秒內確認「這是系統性問題,不是單一使用者的偶發個案」,再帶著這個範圍去 Loki 篩 dependency 欄位,找出集中在哪個依賴,最後只需要打開一兩條具代表性的 trace 驗證細節。兩條路徑最終可能得到同樣的結論,但路徑 A 從一開始就在缺乏範圍資訊的情況下瞎子摸象,路徑 B 每一步都用上一步縮小的範圍去約束下一步要看的資料量。差別不是工具用得熟不熟,而是有沒有先弄清楚「這個訊號本來就適合回答什麼問題」。
Day 02 建起整套 Observability Stack 之後,一直沒說清楚的是:Prometheus 收的 metrics、Alloy 轉送到 Loki 的結構化 log、經 OTel Collector 送進 Tempo 的 trace,各自回答不同層級的問題,不是同一份資料的三種畫法。先把「這個訊號本來就答不了那個問題」講清楚,事故現場才不會浪費時間切換錯的面板。
在講「該點哪個面板」之前,有三個直覺想法要先拆掉,因為它們會讓值班工程師在事故現場做出錯誤的第一步:
這三個誤解背後是同一件事:三種訊號的角色分工不是選配,是它們資料結構上的必然結果。
「Metrics、Logs、Traces 是可觀測性三柱」這個講法,最早由 Peter Bourgon 在 2017 年一篇廣為流傳的部落格文章〈Metrics, Tracing, and Logging〉系統化整理出來,後來被 Distributed Systems Observability(O'Reilly,2018)等書收錄進教材。Bourgon 的核心主張很直白:這三種訊號的資料模型(data model)天生不同,硬把其中一種資料模型套用到另一種訊號上,會產生系統性的浪費或盲點——例如把 metrics 的高基數需求硬塞進本該低基數的 label,或是把該用 log 記錄的離散事件硬湊成一堆 gauge。理解這一點,比背下「三柱」這個詞本身更重要。
What happened?
Prometheus 的 metrics 是預先聚合過的時序數字(counter、histogram、gauge),適合快速判斷「有沒有問題」與「規模多大」,但答不出「這一筆 request 發生了什麼」。
為什麼是預先聚合? 因為 metrics 的設計目標是「用固定、可預期的儲存成本,長期保留能回答趨勢與比例的數字」。每個 timestamp 只存一個數字,不管背後有 10 個還是 10 萬個 request 貢獻了這個數字,儲存量都不會隨流量線性成長——這正是它能撐住秒級告警與跨月比較的原因。代價也在這裡:一旦數字被聚合,原始個體就回不去了。ask_requests_total{outcome="upstream_timeout"} 能告訴你「過去五分鐘有 42 次 timeout」,但問不出「是哪 42 次」。
怎麼運作? Prometheus 是 pull model:Prometheus server 定期向每個服務的 /metrics endpoint 發 HTTP GET,把當下的數值快照存進自己的時序資料庫(TSDB)。這和 push model(服務主動把資料推給收集端,例如 StatsD)是相反的資料流方向:
Push model(StatsD 這類):
App → 每次事件都主動送一包資料 → Aggregator
Pull model(Prometheus):
App 只維護記憶體裡的計數器
Prometheus server 主動每隔 N 秒來抓一次快照
pull model 的好處是服務本身不需要知道「誰在看」,也不會因為監控系統當機而把 App 一起拖垮(App 只是被動被讀取,不需要對外連線送資料);代價是抓取間隔決定了你看到異常的最快速度——如果 scrape interval 是 15 秒,理論上最快也要 15 秒後才會反映在圖表上。
常見誤解:「counter 歸零就代表服務重啟了。」 不完全對。counter 只會遞增,但 Prometheus 服務重啟、Pod 被重新排程、甚至 exporter library 內部重置,都可能讓底層計數器歸零再重新累加。查詢時該用 rate() 或 increase() 這類函式處理,它們懂得偵測歸零並正確地把差值算對,直接比較兩個時間點的絕對值反而會被誤導。
與 logs 的邊界: metrics 能告訴你「upstream_timeout 的比例上升了」,但無法告訴你「是哪個 dependency 造成的」——除非你把 dependency 當成 label,而那正是本文後段要談的高基數陷阱。這個邊界不是工程疏忽,是資料模型本身的取捨:label 基數每多一種組合,就多一條獨立的時間序列,多到一個量級後,Prometheus 自己就會先垮掉。
What exactly happened?
structured log 是一筆一筆的離散事件,每筆都能帶完整 context,適合追「到底發生了什麼」,但事件量一大,靠肉眼掃描就會迷路。
為什麼是離散事件? 因為每一次「發生」本身就是獨立的:一次 dependency 呼叫、一次驗證失敗、一次模型回應,都值得各自留下一筆紀錄,帶著這一刻獨有的細節(哪個 dependency、耗時多久、error 訊息是什麼)。這和 metrics 的聚合特性正好相反——metrics 是「把很多次事件濃縮成一個數字」,logs 是「保留每一次事件各自的樣貌」。兩者不能互相取代:想知道「上升了多少」該問 metrics;想知道「這一次到底發生了什麼」該問 logs。
怎麼運作? 以 Day 02 建立的管線為例,服務把 JSON 格式的結構化 log 寫到 stdout,Grafana Alloy 透過 discovery.docker 找到容器,用 loki.source.docker 把每一行 log 轉送到 Loki。Loki 的索引策略和 Elasticsearch 這類全文索引系統不同——它只替少數低基數的 stream label(例如 service_name、environment)建索引,log 內容本身是壓縮後原樣儲存,查詢時才用 LogQL 的 | json 或 | pattern 對內容做過濾。這個設計讓 Loki 的儲存成本遠低於全文索引系統,代價是查詢速度依賴 label 選得好不好——label 選太寬,一次查詢要掃過的資料量就大。
常見誤解:「log 越詳細越好,寧可多記不要少記。」 Day 02 到現在反覆出現的立場——不要太早抽象、先求最小可運作——在這裡有個對照面:log 這件事反而容易犯「太晚設限」的錯,一路加欄位加到 log 裡混進了 prompt 全文、使用者個資,才在事故後盤點時發現自己早就違反了資料治理承諾。本文 ⑧ 段會具體示範「先問要拿來回答哪個決策,再決定該不該記」的做法。
與 traces 的邊界: logs 能告訴你「retriever 這次 timeout 了」,卻天生沒有母子關係——一筆 log 不會自動知道自己是「哪個更大操作底下的第幾步」,除非你手動把 trace_id 寫進去(本文 ⑦ 段會講這件事有多容易失敗)。這正是為什麼「單靠 log 拼湊出完整的請求路徑」很痛苦:你得靠時間戳記和欄位比對去猜順序,而 trace 天生就把順序和層級結構化好了。
Where did it happen?
distributed trace 把一次 request 拆成一串 span,答的是「卡在哪一段」,但通常是抽樣資料,不能拿來算全站比例。
為什麼是一串 span? 一次跨服務的請求,實際上是好幾個獨立操作接力完成的——ingress 收到請求、呼叫 retriever、呼叫 model、寫回 database。若只留下「這次請求花了 900ms」這一個數字(metrics 會做的事),完全看不出這 900ms 花在哪一段;若只留下每個操作各自的 log(logs 會做的事),又得靠時間戳記手動比對順序。trace 用 span 之間的 parent-child 關係,把整條路徑的時間軸和層級結構天生地保存下來——這是它獨有、metrics 和 logs 都取代不了的能力。
怎麼運作? OpenTelemetry 的 span 帶著 trace_id(整條路徑共用)、span_id(這個操作自己的)、parent_span_id(指向上一層操作),透過 context propagation 在服務邊界之間傳遞。收集端(OTel Collector)把同一個 trace_id 底下所有服務回報的 span 組裝起來,才拼出完整的 trace 樹。本文 ⑨ 段會用 OpenTelemetry Python API 示範怎麼手動開 span。
常見誤解:「trace 涵蓋所有請求,就跟 log 一樣完整。」 幾乎所有 production 環境的 trace 都是抽樣資料,原因很直接:如果每個 span 的完整 attribute(包含 exporter、collector 傳輸與後端儲存的成本)都要對每一筆請求全額保留,成本會隨流量線性暴增,遠比 metrics 或 logs 昂貴。這也是為什麼 trace 適合拿來解釋「代表性案例」,卻不適合拿來當作可靠度的全量統計依據——本文 ⑭ 段的反例二會具體示範這個誤用會導致什麼後果。
把上面三段的「為什麼/怎麼運作/常見誤解」收斂成一張對照表,方便日後查閱:
| 維度 | Metrics | Logs | Traces |
|---|---|---|---|
| 資料型態 | 預先聚合的數字 | 離散事件 | 有母子關係的 span 樹 |
| 收集方式 | pull(Prometheus 主動 scrape) | push(App 寫 stdout,Alloy 轉送) | push(context propagation + exporter) |
| 儲存成本曲線 | 幾乎與流量無關,只怕 cardinality | 與流量線性成長 | 與流量、span 數、sampling rate 都相關 |
| 典型查詢語言 | PromQL | LogQL | TraceQL/trace 搜尋 UI |
| 能回答 | 有沒有問題、規模多大 | 具體發生了什麼 | 卡在哪一段路徑 |
| 天生弱點 | 無法回答個別 request 的細節 | 缺乏跨事件的順序/層級關係 | 通常是抽樣,無法代表全量 |
放在這裡的三個問題是為了配合本文的教學 fixture 設計的,實際服務裡「先查哪個訊號」的對應關係會隨團隊經驗調整——例如支援 exemplar(本文 ⑩ 段提過)的 trace backend,能讓「是哪個 dependency」一步從 metrics 跳過 logs 直接看到 trace 給的答案。這張表要教的不是「永遠照這個順序查」,而是「先想清楚自己在問哪一類問題,再決定該查哪一種訊號」。
這張表不是要你把三種訊號當成互斥的選項——它們共用同一份現實(同一次 request),只是用三種完全不同的資料結構去描述這份現實。三者共用同一個 request_id,才拼得回同一件事:
metric: answer_requests_total{route="/ask", outcome="upstream_timeout"}
log: {request_id, error_class, dependency, retry_count}
trace: /ask → retrieve → model → tool
metrics label 要低基數。不要放 prompt、document ID、完整 error 訊息或 user ID;這些欄位要以存取控制、保留期限與遮罩設計,留在 trace attribute 或 log 欄位裡。
LLM workflow 比一般 API 多兩件事。第一,路徑更長:retrieval、model、tool call 可能疊好幾層,trace 要把這些 span 串起來,才看得出時間花在哪一段,而不是只看到一個籠統的總耗時。第二,多了一批天生高基數又敏感的欄位:prompt 全文、retrieved document ID、使用者原始輸入。這些絕對不能當 metric label——同一個 route 已經因為多種 outcome 有一定基數,再放進去等於讓時序資料庫幫每一筆 prompt 開一條新的時間序列。
model version、prompt version、token usage 這類 AI 特有資訊,適合放進 log 欄位與 trace attribute,交給 Loki 與 Tempo 承接,而不是塞進 Prometheus 的 label 集合。
傳統 REST API 的 trace 樹通常只有兩三層:ingress → 商業邏輯 → database。AI workflow 常見的樹形明顯更深,每一層都可能再往下展開:
傳統 CRUD API:
/orders (SERVER)
└─ query_database (CLIENT)
AI Workflow(RAG + tool use):
/ask (SERVER)
├─ retrieve_documents (CLIENT)
│ ├─ embed_query (INTERNAL)
│ ├─ vector_search (CLIENT)
│ └─ rerank (INTERNAL)
├─ build_prompt (INTERNAL)
├─ call_model (CLIENT)
│ └─ [model provider 內部的排隊、重試不可見]
├─ run_tool (CLIENT)
│ └─ tool_side_effect (CLIENT)
└─ validate_answer (INTERNAL)
這棵樹的深度直接影響「哪一段慢」的可回答性。若只留一層(root span 900ms),你只知道整體慢;展開到 vector_search 和 rerank 各自的耗時,才看得出是 embedding 慢、向量資料庫慢,還是常被忽略的 rerank 步驟拖了時間。這也解釋了 Day 18 討論的 fan-out 延遲放大——retrieval 本身可能是對多個 shard 的平行查詢,一旦某個 shard 卡住,root span 的耗時會被它拖著走,只有把 span 展開才看得出來,光看 metrics 的 P95 只會告訴你「變慢了」。
除了 prompt 全文之外,AI workflow 還容易不小心讓下列欄位溜進 label:
不該當 label 的欄位:
user_id — 每個使用者一條新時間序列
session_id / conversation_id — 同上,且通常是隨機字串
document_id / chunk_id — retrieval 命中的文件通常有成千上萬種組合
raw error message — 每次可能夾帶不同的參數或內部路徑
prompt_hash(若雜湊空間夠大)— 表面上看起來「已經脫敏」,實際上基數跟 prompt 本身一樣高
可以當 label 的欄位:
route, outcome, workflow, model_provider(有限集合)
prompt_version, workflow_version(有版本號但數量有限)
deployment_environment(staging/production 這種固定集合)
prompt_hash 值得特別提一下,因為它常被誤以為是安全折衷——「反正雜湊過了,應該可以當 label」。基數問題和內容是否可讀無關:只要每個不同的 prompt 產生不同的雜湊值,label 基數就等於 distinct prompt 的數量,一樣會把 Prometheus 炸開。真正安全的做法是把 prompt 相關資訊限制在 log 欄位或 trace attribute,而不是想辦法在 metric label 裡「藏」高基數資料。
AI workflow 的 span attribute 該叫什麼名字,不需要每個團隊各自發明。OpenTelemetry 從 2023 年起逐步制定了 gen_ai.* 這組 semantic conventions,涵蓋模型呼叫的常見欄位:
gen_ai.system — 供應商識別(例如 "openai"、"anthropic")
gen_ai.request.model — 請求時指定的模型名稱
gen_ai.response.model — 實際回應的模型(可能因 routing 而不同)
gen_ai.usage.input_tokens — 輸入 token 數
gen_ai.usage.output_tokens — 輸出 token 數
gen_ai.request.temperature — 取樣溫度等生成參數
用官方定義的欄位名稱,最直接的好處是可攜性:若 Day 32 要比較 Langfuse 與 LangSmith 這兩個 LLM observability 平台,兩邊若都支援解析 gen_ai.* 屬性,span 資料不需要重寫就能被兩邊讀懂——這和本文 ⑦ 段「資料契約應該被當成介面」是同一個立場,只是這裡有現成的業界標準可以直接採用。實務上 ai.workflow.name、ai.workflow.version 這類本文自訂欄位,處理的是 gen_ai.* 尚未涵蓋的「業務層 workflow 識別」,兩者並不衝突。
Day 02 已經把 Prometheus、Loki、Tempo、Alloy、OTel Collector 整套服務串起來了,直覺上今天的 Lab 應該直接把服務跑起來、真的送一筆請求進去、在 Grafana 上點來點去驗證。這篇文章刻意不這樣做,原因和本系列一貫的立場一致:先確認「三種資料能不能靠同一個 request_id 拼回同一件事」這個最小、最根本的問題,比先把整套基礎設施跑起來更重要。如果連一個手造的 fixture 都串不起三種資料的關聯鍵,把它接上真正的 Docker Compose 服務只會讓除錯的變數變多——你分不清是關聯鍵設計本身有問題,還是 Alloy 的收集規則、OTel Collector 的 pipeline 設定出了狀況。
今天的 Lab 因此設計成「先在紙上(或最小可執行的程式碼片段)驗證資料契約」,等這一層確認沒問題,再往下接真正的服務——這正是 Day 02 到 Day 03 那條「先求最小可運作,再逐步演化」路線的延續,只是這次「最小可運作」指的不是服務本身,而是資料契約本身。
選一個受控 fixture,讓它產生一筆 counter、一筆 JSON log、一條至少兩個 span 的 trace。三種資料都要共享同一個 request_id,但 metrics 不把它當 label——只在 log 與 trace 裡出現。
{"request_id":"demo-42","event":"dependency_timeout","dependency":"retriever"}
接著寫下三個查詢問題:「timeout 有沒有變多?」「是哪個 dependency?」「這筆 request 花在哪?」每題只指定一種主要訊號作答,不允許用另外兩種訊號硬湊答案。
這個練習故意設計得很小——一個 fixture、一個 request_id、三種資料——原因是它逼你先確認「資料契約」(同一個 request_id 能不能在三種資料裡都找到)再談任何 dashboard 或告警規則。Day 02 建好整套 Observability Stack 之後,很容易跳過這一步,直接開始堆 Grafana panel;但如果三種訊號之間連基本的關聯鍵都串不起來,堆再多面板也只是三份互不相干的資料各自躺在螢幕上。
把 SRE Lab 的三個查詢問題對回三種訊號,結果是這樣一張對照表:
| 問題 | 先查的訊號 | 為什麼 |
|---|---|---|
| timeout 有沒有變多? | metrics | 已經聚合成比例與趨勢,一眼看出規模 |
| 是哪個 dependency? | logs | error_class、dependency 欄位可篩選、可比對 |
| 這筆 request 花在哪? | trace | span 順序與耗時直接呈現路徑 |
這張對照表看起來簡單,卻是整套可觀測性設計最終要落地的地方。它和 Day 14 的 SLI/SLO 對照表、Day 20 的 quality budget 表有一個共通結構:都是「一個判斷問題」對「一種明確可查的訊號」,而不是「一堆資料丟出來,靠工程師的經驗去感覺」。差別在於 SLI/SLO 表回答的是「服務有沒有達標」,這裡的表回答的是「達標與否的判斷過程要用哪種資料」——前者是目標,後者是找到目標背後根因的工具箱分類。
驗證方式是拿同一個 demo-42 分別在三種資料裡查一次:metrics 只能給出「outcome 是 upstream_timeout 的比例」,看不到是哪個 dependency;log 能篩出 dependency="retriever",卻看不出這筆 request 在整條路徑上花了多久;trace 能秀出 /ask → retrieve → model → tool 的耗時分布,卻不知道這是不是全站現象。三個問題各自對到一種主要訊號,沒有一種資料能單獨回答另外兩題。
有個直覺的反駁是:既然三種資料都共用 request_id,為什麼不能先查 log 找出所有 timeout 的 dependency,再統計出現次數,自己算出比例,等於用 log 湊出 metrics 該回答的問題?
技術上做得到,但代價說明了為什麼不該這樣做。Loki 掃描原始 log 內容是線性成本,log 量越大、查詢窗口越長,掃描時間和運算成本就越高;Prometheus 的 rate() 則是對已聚合好的時序資料做算術,秒級完成,不太受查詢窗口長短影響。用 log 湊比例,等於每次回答「有沒有問題」都得付一次鑑識等級的成本——這在事故現場是浪費,在日常告警規則裡更不可行(沒有人會讓告警規則每次觸發都去掃全文 log)。
這也是三種訊號分工的根本理由:每種資料的儲存結構決定了它天生擅長回答哪種問題,用錯訊號硬湊答案,付出的成本不成比例。
只有 metrics:知道火警響了,不知道誰在燒。只有 logs:每次事故都靠全文搜尋大海撈針。只有 traces:能看懂一筆故事,卻不知道這是不是大規模問題。
這三句話各自對應一種常見的團隊發展階段,不是隨口的比喻。很多服務起步時只上了 metrics(畢竟 Prometheus 的 client library 幾行程式碼就能掛上),告警確實會響,但響了之後值班工程師只能憑經驗猜測、或是臨時加 print 語句、重新部署才能看到更多細節——這正是「知道火警響了,不知道誰在燒」最真實的樣貌。等到吃過幾次「猜錯方向、修復拖延」的苦,才會補上結構化 logging;而 trace 通常是最後才補上的一塊,因為它的投資報酬率在單體服務時期並不明顯,只有當系統真的長成分散式架構、一次請求要跨過好幾個服務邊界時,「卡在哪一段」這個問題才會變得無法用 log 時間戳記手動拼湊出來。三種訊號的補齊順序,某種程度上也反映了一個系統從簡單走向複雜的過程——這也呼應了 CLAUDE.md 裡「不要太早抽象、先求最小可運作再逐步演化」的立場:不是一開始就該三種齊備,而是隨著系統複雜度增加,補上對應的觀測能力。
Google SRE Book 的 Shakespeare Search postmortem 很適合拿來練習這個順序。先說清楚它的性質:官方文件明講這是書裡示範「postmortem 該怎麼寫」的虛構情境,不是某一次真的 Google production incident,但寫法與數字都是書中原文,值得照它的排查順序拆解。情境是:一首「新發現」的莎士比亞十四行詩上線,其中一個從未在莎翁作品中出現的詞彙被大量搜尋,觸發異常處理路徑,而該路徑有一個 file descriptor 未被正確釋放的缺陷。隨著查詢量增加,file descriptor 逐漸耗盡,最終導致全服務在 66 分鐘內完全不可用,估計約 1.21 億個查詢遺失。排查過程恰好反映了兩個訊號的角色:告警先在秒級偵測到 HTTP 500 rate 變成 100%(metrics 說「著火了」),接著工程師從伺服器 log 與 stack trace 發現是 file descriptor 耗盡(logs 說「資源在哪裡被耗盡」)。這個示範案例沒有涉及現代 distributed tracing,不要假裝它提供了 span 圖;拿教學案例來練排查方法,不等於替它補上當年不存在的 telemetry。Google SRE Book:Example Postmortem
Meta 在調試分散式 AI 訓練失敗時踩過類似的坑。Meta 開發了 Logarithm——一個每秒索引 100+ GB 日誌的託管結構化日誌系統——專門用於訓練故障診斷。分散式訓練常見的故障是「某個 GPU rank 的 gradient allreduce 被 hang 住」,這種故障在 metrics 裡只看得出「訓練變慢了」,答不出「卡在哪一步、哪個 rank」;工程師需要跨所有 rank 的結構化 log 時間序列比對,才能發現「第 14 個 rank 的梯度同步被阻塞」。這兩個案例讓我調整了一個習慣:與其先蒐集所有可蒐集的欄位,不如先想清楚「這筆資料要拿來回答哪個決策」,再決定它該長在 metric、log 還是 trace 裡。
把 Shakespeare Search 和 Meta Logarithm 放在一起看,會發現兩者面對的問題形狀其實相反,卻得出相同的訊號分工結論:
| Shakespeare Search | Meta Logarithm | |
|---|---|---|
| 故障類型 | 使用者請求路徑上的 resource leak | 分散式訓練裡某個 GPU rank 被卡住 |
| metrics 能看到什麼 | HTTP 500 rate 瞬間衝到 100% | 訓練速度變慢,但看不出是哪一步 |
| 缺的那一塊 | 若沒有 log,看不出是 file descriptor 耗盡 | 若沒有跨 rank 的結構化 log,看不出是哪個 rank 卡住 |
| 補上後的效果 | log 的 stack trace 直接指向資源洩漏的程式碼路徑 | 跨 rank 時間序列比對揪出「第 14 個 rank」 |
兩個案例的服務型態、故障成因完全不同(一個是傳統 web 服務的記憶體資源洩漏,一個是分散式機器學習訓練的同步阻塞),卻共享同一個結構:metrics 先確認「有異常、規模多大」,log 才能回答「異常的具體機制是什麼」。這種跨場景的一致性,正是①到③段主張「三種訊號的分工是資料結構決定的,不是某個特定領域的巧合」最好的印證。Meta Engineering:Logarithm Logging Engine
這兩個案例都在講「單一訊號缺一角會怎樣」,但缺角造成的損失不是均勻分布的——不同訊號的偵測延遲本身就不一樣,這個差距在跨系統故障裡會被放大成獨立的盲區。多篇公開的事故分析記錄過同一種模式:一次由客戶端 API 呼叫模式變化引發的錯誤,在應用層 metrics 上升的時間比基礎設施層 metrics(CPU、記憶體、網路流量)早了整整 12 分鐘——若告警規則只綁基礎設施指標,這 12 分鐘的根因線索會完全消失。另一個貼近 AI workflow 資料庫依賴的模式:一次緩慢的 SQL 查詢立即反映在應用層 latency,卻要等 3 到 5 分鐘後才出現在資料庫自己的 query execution log 裡,原因是 log 的非同步寫入與批次 flush 機制。這補上了教科書案例沒講的一塊:三種訊號不只「回答不同問題」,連「多快回答」都不一樣,只盯著其中一種,等於在盲區裡多賭上好幾分鐘的偵測延遲。ADHDecode:Observability Blind Spots | OneUptime:Turn "Improve Monitoring" into a Testable Postmortem Action
把 request_id 寫進 log,還不夠。它必須有來源、有傳遞規則,也要知道它和 trace ID 是兩個不同概念。
request_id:產品或 API 層的請求識別值,通常回傳給呼叫端或寫進 audit log。
trace_id:可觀測性系統為一條分散式路徑使用的識別值。
span_id:該路徑裡一個操作節點的識別值。
一個 HTTP request 最理想的情況是同時帶著三者:request_id 讓客服、產品與工程師談的是同一筆業務請求;trace_id 讓工程師從入口一路看到 retriever、model provider 與 tool;span_id 則精確指向「這一個 retriever call」。
不要把三者硬合併成一個字串。外部系統可能已用 W3C Trace Context 傳入 trace ID;產品端也可能需要自己的訂單、對話或任務識別。保留各自語意,搜尋時再交叉查,會比把所有欄位塞進 request_id 好維護。
這個問題值得認真回答,因為「合併成一個 ID」聽起來確實比較簡單。答案在於三者的生命週期完全不同步:
request_id 的生命週期:
由 API 層產生 → 貫穿整個業務流程(可能含多次 retry、多個背景工作)
→ 可能被寫進資料庫、回傳給客戶端、出現在客服工單裡
→ 存活時間:以「這筆業務請求」為單位,可能是幾秒,也可能是一個跨天的非同步工作
trace_id 的生命週期:
由第一個收到請求的服務(或更早的 client SDK)產生
→ 只在這一次「觀測到的分散式呼叫鏈」裡有意義
→ 存活時間:由 sampling 決定,可能在 collector 決定「不保留」的那一刻就已經失效
span_id 的生命週期:
只描述「這一個操作」,操作一結束,span 就關閉
→ 存活時間:毫秒到秒級
如果把三者合併成一個字串,最先出問題的是 retry:同一個 request_id 被賦予三次 retry 機會時,每次理論上該是新的 trace,但若 ID 合併了,你要嘛失去區分三次 retry 的能力,要嘛被迫讓 request_id 跟著變動——而它一旦變動,客服和產品端拿著使用者回報的編號就對不上任何一次呼叫鏈了。分開維護三個 ID,正是為了讓「業務語意的穩定性」與「觀測系統的取樣與生命週期」各自自由變動、不互相牽制。
寫出 {"request_id": "demo-42", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"} 之後,容易有一種錯覺:字串對了、格式也對了,事故時點下去就能跳到那條 trace。實務上這件事會在四個各自獨立的環節斷掉,任何一個環節出包都足以讓「看起來合法的 trace_id」連到空氣:
0x、括號或 vendor 的 URL 前綴、大小寫沒統一——W3C 規格要求 trace ID 固定 32 碼小寫十六進位、span ID 16 碼,差一個字元,下游解析就直接失敗。traceparent,卻誤把呼叫端的 parent span ID 當成自己的 span ID 寫進去;或是在 executor 邊界、pooled thread 上,context 被前一個請求留下,串到下一個不相關的請求身上。這四項提醒了一件事:trace_id 出現在 log 裡,只是「必要條件」,不是「充分條件」。runbook 若寫「用 trace_id 查」,也該補一句「查不到時,先確認是格式、context、取樣,還是目的地問題」,而不是預設查不到就等於這條 request 沒發生過。OneUptime Engineering Blog:Why Trace IDs in Logs Fail to Link
第一項「格式本身錯了」是四項裡唯一能在寫入前就靜態攔下來的,值得留一個最小驗證:
import re
_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_SPAN_ID_RE = re.compile(r"^[0-9a-f]{16}$")
def is_valid_trace_id(value: str | None) -> bool:
return bool(value) and _TRACE_ID_RE.match(value) is not None and value != "0" * 32
def is_valid_span_id(value: str | None) -> bool:
return bool(value) and _SPAN_ID_RE.match(value) is not None and value != "0" * 16
value != "0" * 32 這行特別容易被忽略:W3C 規格把全零的 trace ID 定義為「無效」(通常代表 no-op span),格式正確不代表這個 ID 真的指向一條有意義的 trace。把這個檢查放進 logging 中介層,能在寫入時就過濾掉第一種失效原因,讓查不到的案例集中在後面三種真正需要排查的情境上。
/ask 的欄位契約以下是教學用的資料契約。值都是 fixture,沒有連到任何真實使用者或 provider。
HTTP header: X-Request-ID: demo-42
log fields:
request_id=demo-42
trace_id=4bf92f3577b34da6a3ce929d0e0e4736
service.name=policy-api
service.version=2026.09.21
workflow.name=policy-rag
workflow.version=v3
trace resource/span attributes:
service.name=policy-api
service.version=2026.09.21
deployment.environment=staging
ai.workflow.name=policy-rag
ai.workflow.version=v3
app.request_id=demo-42
metric labels:
route=/ask
outcome=success|upstream_timeout|validation_failed
workflow=policy-rag
留意這份契約裡欄位命名的細節:log fields 用 snake_case(request_id),trace attributes 卻用 dot.notation(service.name)——這不是隨意的風格差異,而是分別沿用兩個生態系各自的慣例:OpenTelemetry semantic conventions 全面採用 dot.notation,多數 JSON logging 工具鏈則習慣扁平的 snake_case。硬要統一反而讓人彆扭;比較實際的作法是讓每個系統遵循自己所屬生態系的慣例,把「兩邊語意是否一致」當成需要人工核對、寫進契約文件的地方。
service.version、workflow.version 與 deployment.environment 都是有限集合,適合當成受控維度。request_id 不是;它只能出現在 log 和 trace,而不是 metrics label。
這份契約應該被當成介面,而不是某個後端的私有欄位表。未來從 Tempo 換到別的 trace backend,或在 Day 32 比較 Langfuse 與 LangSmith 時,request_id、版本與 outcome 的意思不應跟著平台改名。
以下 FastAPI 範例只示範邊界與欄位;讀者可自行放進 DIY 專案。它不會在本文中被執行。
from __future__ import annotations
import logging
from uuid import uuid4
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger("policy_api")
class RequestContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or str(uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
app = FastAPI()
app.add_middleware(RequestContextMiddleware)
這段故意沒有直接信任任何任意長度的 header 值。實際服務還要訂格式、長度與字符集限制,避免呼叫端拿 header 當 log-injection 載體;若 request ID 涉及資安稽核,還要決定 proxy 是否能覆寫它。
上面這句提醒值得展開,因為「不信任外部輸入」講起來容易,實作時常常漏掉細節。一個較完整的版本會在接受呼叫端傳入的 X-Request-ID 之前,先做格式與長度檢查:
import re
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
def sanitize_request_id(raw_value: str | None) -> str:
if raw_value and _REQUEST_ID_RE.match(raw_value):
return raw_value
return str(uuid4())
這裡的重點不是正則表達式本身,而是「拒絕不符合格式的輸入,而不是嘗試修剪或轉義它」。若呼叫端傳來的值帶著換行符號、control character 或超長字串,直接原封不動寫進 JSON log,輕則讓 log 格式跑掉(換行符號會讓一筆 log 在肉眼掃描或某些 parser 眼中變成兩行),重則被利用來偽造看似合法的 log 內容(log injection)。遇到不符合格式的值,最安全的處理方式是完全捨棄、改用自己產生的 UUID,而不是試著「修好」呼叫端傳來的髒資料。
OpenTelemetry 的 context propagation 有標準傳遞機制。對下游 HTTP、RPC 或 queue producer,優先讓 instrumentation 或官方 propagator 處理;手動把 trace_id 複製進自訂 header,很容易在 retry、background task 或 async boundary 斷掉 parent-child 關係。
而 request_id 是應用程式欄位。它可以被加進 span attribute,也可以被放進 structured log,但不應假裝自己是 trace parent。這兩個概念相似,責任不同。
前面的例子都用 HTTP 示範,但若之後把某些非同步 AI 任務(例如批次 embedding、離線評估)改成用佇列處理,propagation 不會因為換了傳輸方式就自動消失,只是要換一種載體:
import json
from opentelemetry.propagate import inject, extract
def publish_task(queue, payload: dict) -> None:
headers: dict[str, str] = {}
inject(headers) # 由 OTel propagator 填入 traceparent 等欄位
queue.push(json.dumps({"headers": headers, "payload": payload}))
def consume_task(queue) -> None:
message = json.loads(queue.pop())
ctx = extract(message["headers"])
with tracer.start_as_current_span("worker.handle_task", context=ctx):
process(message["payload"])
inject()/extract() 是通用機制,不限定 HTTP header——只要能夾帶字串鍵值對的載體(訊息 header、metadata)都能用同一套 API。用 span link 還是延續 parent-child,取決於生產端是否還在等待處理結果:fire-and-forget 適合用 link;同步等待回應則延續 parent-child 更貼近實際因果順序。
以 outbound HTTP 呼叫為例,OpenTelemetry 的 instrumentation 通常會自動處理 propagation,開發者不需要手動組字串:
import httpx
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
# 啟用後,透過這個 client 送出的每個請求,
# 都會自動帶上正確的 traceparent header——
# 不需要手動讀取 current span、組字串、塞進 headers dict。
HTTPXClientInstrumentor().instrument()
async def call_retriever(query: str) -> dict[str, object]:
async with httpx.AsyncClient() as client:
response = await client.post(
"http://retriever-service/search",
json={"query": query},
)
return response.json()
對照如果手動處理會長什麼樣子——也正是容易出錯的版本:
# 手動版本:容易在 retry、背景工作或多執行緒環境下遺漏或算錯 parent_span_id。
current_span = trace.get_current_span()
span_context = current_span.get_span_context()
traceparent = (
f"00-{format(span_context.trace_id, '032x')}"
f"-{format(span_context.span_id, '016x')}-01"
)
# 若複製到沒有 active span 的 context(例如背景排程任務),
# current_span 會是「no-op span」,trace_id 全部是 0,
# 產生一條看起來合法、實際上毫無意義的 traceparent。
差別不在於「手動版本寫得不好」,而在於官方 instrumentation 已經處理好了 async 邊界、執行緒切換、exception 傳遞這些容易出錯的細節;手動重寫一遍,等於把責任攬到自己身上,卻沒有官方 SDK 那樣經過大量生產環境驗證的覆蓋率。
「手動傳 header 容易斷」不是理論假設。Uber 從 2015 年約 500 個微服務成長到 2017 年初的 2,000 多個,內部早期的 tracing 系統「完全沒有 distributed context propagation 的概念」,服務數一多,追蹤就斷在第一層之後。後來團隊改用 Tornado(非同步 Python framework)重建 tracing,卻踩到另一個更隱蔽的坑:Tornado 的 IOLoop 讓同一個 thread 同時跑著多個互不相關的 request,原本仰賴 thread-local storage 存放 trace context 的作法整個失效——thread-local 變數會被不同 request 互相覆寫,串錯 parent-child 關係。這正是「手動把 trace_id 複製進自訂 header 容易在 async boundary 斷掉」的真實規模版,也是 Uber 後來全面走向 explicit context propagation、並催生 Jaeger 的直接原因。Uber Engineering:Evolving Distributed Tracing at Uber Engineering
Jaeger 後來成為 CNCF 專案,它的資料流動設計也呼應了 Day 02「Collector 夾在 Application 與後端存儲中間」的架構原則:Application 只需要知道「把 span 丟給本機 agent」,不需要知道背後儲存後端是什麼、會不會換。Uber 從「thread-local context 常常串錯」的教訓得到的另一個啟示,是「傳遞機制」與「儲存後端」要分開設計——前者決定 trace 資料本身正不正確,後者只決定資料存到哪裡、能查多久。
「有 log」通常只表示 print() 還活著。事故時能不能用,取決於每筆事件是否有穩定欄位,以及高風險內容是否先被排除。
先定義事件名稱。比起這樣:
retriever failed again maybe timeout
更適合查詢的是這樣:
{
"event": "dependency_call_finished",
"request_id": "demo-42",
"dependency": "retriever",
"outcome": "timeout",
"retry_count": 1,
"duration_ms": 820,
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
事件名稱描述「發生什麼」,欄位則描述「對誰、結果如何、花多久」。兩者都要穩定。若同一件事今天叫 retrieval_error、明天叫 search_failed,Loki 查詢很快會變成考古。
上面的問題實務上通常不是單一開發者反覆改名造成的,而是團隊裡不同人各自替同一類事件取了不同名字。最直接的做法是替服務訂一份簡短的事件詞彙表,固定動詞和名詞的組合方式:
命名慣例:<subject>_<verb 過去式>
dependency_call_started
dependency_call_finished ← outcome 欄位帶結果,不要另開 dependency_call_failed
model_call_finished
validation_completed
workflow_completed
反例(避免):
retrieval_error ← 「error」該是 outcome 欄位的值,不是事件名稱本身
search_failed_again ← 「again」是敘述性文字,不是穩定欄位
timeout_on_retriever_call_v2 ← 版本號混進事件名稱,改版就要改查詢
規則很簡單:事件名稱回答「這是哪一類動作」,結果永遠交給 outcome 欄位表達,不要編進事件名稱裡。這樣同一個 LogQL 查詢(event="dependency_call_finished")能同時涵蓋成功與失敗,只需再加一個 outcome 條件去篩,不必記住每種失敗各自叫什麼名字。
除了 event 和 outcome,log level(DEBUG/INFO/WARNING/ERROR)也常被隨便用——不是全塞 INFO,就是把每個 dependency timeout 都設成 ERROR,導致 ERROR level 在正常流量下也天天出現,久而久之沒人再相信它真的代表需要處理的異常。
一個實務上可行的分法:
DEBUG — 開發時才需要,production 預設關閉或極低比例抽樣
INFO — 正常業務事件(request 完成、dependency 呼叫成功)
WARNING — 已被容錯機制吸收的異常(retry 後成功、fallback 生效)
ERROR — 使用者可感知的失敗,且不在系統的預期容錯範圍內
upstream_timeout 若已被 retry 吸收、最終成功回應使用者,比較合理的 level 是 WARNING 而非 ERROR——值得記錄、值得統計,但不代表「這次請求對使用者來說失敗了」。留住 ERROR 的稀有性,告警規則綁定它時才有意義。
import json
import logging
from datetime import datetime, timezone
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"event": getattr(record, "event", "application_log"),
"message": record.getMessage(),
"request_id": getattr(record, "request_id", None),
"trace_id": getattr(record, "trace_id", None),
"dependency": getattr(record, "dependency", None),
"outcome": getattr(record, "outcome", None),
}
return json.dumps({key: value for key, value in payload.items() if value is not None})
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("policy_api")
logger.handlers = [handler]
logger.setLevel(logging.INFO)
呼叫點要帶結構化欄位,而不是把欄位串在 message 裡:
logger.info(
"retriever request finished",
extra={
"event": "dependency_call_finished",
"request_id": request.state.request_id,
"trace_id": current_trace_id,
"dependency": "retriever",
"outcome": "timeout",
},
)
上面的 current_trace_id 是待讀者自行接到 OTel context 的位置。不要為了讓範例看似完整而亂塞一個 UUID;若 log 的 trace ID 和實際 span 不同,按一下連結只會打開另一條無關 trace,比沒有連結更浪費時間。
事故剛發生時,常見錯誤是直接搜尋 error。那等於拿一個很大的手電筒照整片海。
先用低基數 stream label 選出服務,再用 JSON 欄位縮小:
{service_name="policy-api", environment="staging"}
| json
| event="dependency_call_finished"
| outcome="timeout"
若已從 alert 或客服回報拿到 demo-42,再查單一請求:
{service_name="policy-api", environment="staging"}
| json
| request_id="demo-42"
如果連 dependency 都還不確定,先看一段時間內各 outcome 的分布,再決定要不要進一步篩 dependency:
sum by (outcome) (count_over_time(
{service_name="policy-api", environment="staging"}
| json
| __error__=""
[5m]
))
這是「先窄後寬」反過來的用法:連要篩什麼欄位都還不知道時,先用 sum by (outcome) 把過去五分鐘的事件依 outcome 分組計數,看哪個量最大再決定深入方向。__error__="" 常被忽略,它過濾掉 JSON parse 失敗的行——若服務混雜非 JSON 格式的舊版 log,不加這個條件會讓解析失敗的行被當空欄位算進統計,使分布失真。
Loki 的 stream label 也有 cardinality 成本:service_name、environment、cluster 這類有限集合可作 label;request_id、trace_id、完整 URL、使用者帳號和 prompt 不能,應保留為 JSON 欄位用 filter 查,不要變成 index label。
預設答案應該是「先不記」。AI 系統的 prompt、retrieved chunk 和 model output 很可能含內部文件、個資或機密。這個問題在傳統 CRUD API 裡幾乎不會出現——訂單 API 的 request body 通常是結構化欄位(商品編號、數量、地址),本來就適合直接記錄;但 AI workflow 的 input 天生是自由格式的自然語言,使用者可能不小心貼上病歷號碼、身分證字號,或內部文件的敏感段落。若除錯真的需要片段,先問四件事:
這四個問題共通的判斷邏輯是:把「要不要記錄原始內容」轉換成「能不能用更抽象的代理指標達到同樣的除錯效果」。多數時候答案是能——長度、雜湊、版本號、分類結果組合起來,足以回答「這次輸入是否異常大、是否命中已知問題模式、是否換版後才開始出錯」,不需要真的看到內容本身。
一個比較保守的 AI event 可以長成這樣:
{
"event": "model_call_finished",
"request_id": "demo-42",
"prompt_version": "policy-answer-v3",
"model_name": "example-model",
"input_chars": 418,
"output_chars": 126,
"retrieval_document_count": 3,
"contains_user_content": true,
"content_logged": false,
"outcome": "success"
}
這仍然足以回答版本切換後的問題,卻不會把原文直接送到每個可讀 Loki 的帳號面前。
常見的兩種策略是 denylist(列出禁止記錄的欄位,其餘照記)與 allowlist(列出允許記錄的欄位,其餘一律排除)。denylist 的結構性弱點是它永遠只能防住「已知」的敏感欄位——下一次有人在 request schema 新增 internal_notes 或 customer_ssn,denylist 不會自動涵蓋,除非有人記得同步更新。
# denylist:容易漏掉「還沒被想到」的新欄位
SENSITIVE_FIELDS = {"prompt", "email", "phone", "ssn"}
def redact_denylist(payload: dict) -> dict:
return {k: v for k, v in payload.items() if k not in SENSITIVE_FIELDS}
# allowlist:新欄位預設就是不會被記錄,安全邊界更穩定
LOGGABLE_FIELDS = {"request_id", "schema_version", "outcome", "duration_ms"}
def redact_allowlist(payload: dict) -> dict:
return {k: v for k, v in payload.items() if k in LOGGABLE_FIELDS}
allowlist 的代價是「多一個欄位想記錄,就要主動改一次清單」,但這正是它的價值——強迫每次新增可記錄欄位都經過「安不安全」的人工判斷,而不是預設什麼都能記、事後才補禁令。對不斷長出新欄位的 AI workflow,這種「預設拒絕」的姿態比 denylist 更符合本系列一貫的保守立場。
structured log 的欄位集合會隨時間演化——今天加一個 retry_count,下個月拆分 dependency 成 dependency_name 和 dependency_tier。若沒有機制標記「這筆 log 是哪個 schema 版本寫的」,事故當下查詢舊資料可能困惑「為什麼一半的 log 有這個欄位,一半沒有」。最省事的做法是讓每筆 log 帶一個 log_schema_version 欄位,查詢時先確認欄位是否存在。
下一篇會接著處理 trace 該怎麼拆成能做決定的 span、metrics 型別與 cardinality 該怎麼管、一個 12 分鐘的排查練習,以及 sampling、retention 與上線前的最小 runbook。
這篇是 Learning SRE for the AI Era 系列的一部分。
我會從 SRE 的服務可靠性基礎開始,逐步探索當系統加入 LLM、RAG、Agent 與 GPU Infrastructure 後,如何讓 AI 系統不只可用,也能被觀測、評估、控制成本並安全演進。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.