結論先說:trace 要拆到能做決定、metrics label 要受控、sampling 與 retention 要寫進治理表——這三件事沒做對,再漂亮的 dashboard 底層接的都是連不起來的資料。
上篇談了 metrics、logs、traces 三種訊號各自的邊界,AI workflow 多出來的路徑與敏感欄位,並用一個最小 DIY 練習定義了貫穿三種訊號的關聯鍵:request_id、trace_id、span_id 是三個生命週期不同的概念,硬合併成一個字串只會讓查詢更難維護。這篇接著把關聯鍵落地成可執行的 span 設計、metrics 治理與排查流程。
span 不是越多越好。若每一行 helper function 都開 span,trace 會像把所有螺絲攤在桌上:資訊很多,卻找不到故障面。
對 /ask 而言,第一層可從使用者可感知的邊界開始:
/ask (SERVER)
├─ authenticate (INTERNAL)
├─ retrieve_documents (CLIENT 或 INTERNAL)
├─ build_prompt (INTERNAL)
├─ call_model (CLIENT)
├─ run_tool (CLIENT)
├─ validate_answer (INTERNAL)
└─ serialize_response (INTERNAL)
每個 span 的判斷標準很簡單:若它慢了、錯了或被換版,on-call 是否會做不同的事?會,就值得有一個 span。只是一個字串格式化,通常不用。
上面的樹狀圖裡,每個節點後面標了 SERVER、CLIENT、INTERNAL,這是 OpenTelemetry 定義的 SpanKind,作用是告訴後端「這個 span 在分散式呼叫鏈裡扮演什麼角色」,而不只是給人看的裝飾字。
SERVER — 這個服務正在處理一個外部進來的請求(例如 /ask 的 root span)
CLIENT — 這個服務正在對外呼叫另一個服務或系統(例如呼叫 model provider)
INTERNAL — 純粹是這個服務內部的操作,沒有跨越服務邊界(例如 build_prompt)
PRODUCER — 這個服務送出一則訊息到 queue,但不等待處理結果
CONSUMER — 這個服務從 queue 取出訊息並處理
SpanKind 標對了,trace 後端才能畫出「服務地圖」——用 SERVER 和對應的 CLIENT span 配對,自動推導這個服務呼叫了哪些下游。若全標成 INTERNAL,後端分不清哪段是本地運算、哪段是網路呼叫,也就畫不出跨服務依賴圖。
trace 樹能跨服務串起來,靠的是 context propagation——每次跨服務呼叫(HTTP、gRPC、訊息佇列)都要把目前的 trace context 帶過去,讓下游服務知道「我開的下一個 span,parent 是誰」。W3C Trace Context 標準定義了 HTTP header 的格式:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
版本 trace_id(32 碼 hex) parent span_id(16 碼 hex) flags
下游服務收到 traceparent 後,解析出 parent 是誰,開自己的 span 時沿用同一個 trace_id、把 parent_span_id 指向剛剛解析出來的值——如此一路傳遞,trace 樹才能跨服務串起來。這套機制用官方 SDK instrumentation(例如 opentelemetry-instrumentation-fastapi)時是自動完成的——這也是⑦段強調「不要自己手寫 header」的原因:手動複製 trace_id 卻忘了同步更新 parent_span_id,或在 async/背景工作裡遺失 context,整條鏈就會斷成兩截各自獨立的 trace。
這是依 OpenTelemetry Python API 的概念示範。讀者要自行依專案的 SDK、exporter 與 framework instrumentation 補齊初始化,本文不會執行它。
from opentelemetry import trace
tracer = trace.get_tracer("policy_api.workflow")
def answer_question(question: str, request_id: str) -> dict[str, object]:
with tracer.start_as_current_span("workflow.ask") as root:
root.set_attribute("app.request_id", request_id)
root.set_attribute("ai.workflow.name", "policy-rag")
root.set_attribute("ai.workflow.version", "v3")
with tracer.start_as_current_span("retrieval.search") as retrieval:
retrieval.set_attribute("ai.retrieval.index_version", "policies-2026-09")
documents = search_documents(question)
retrieval.set_attribute("ai.retrieval.document_count", len(documents))
with tracer.start_as_current_span("llm.generate") as generation:
generation.set_attribute("gen_ai.request.model", "example-model")
answer = call_model(question, documents)
with tracer.start_as_current_span("validation.source_check") as validation:
result = validate_sources(answer, documents)
validation.set_attribute("app.quality_status", result.status)
return {"answer": answer, "status": result.status}
範例刻意沒有把 question、documents 或 answer 設為 attribute。attribute 不是保密資料庫;它會流向 exporter、collector、後端、備份與支援人員可能看的畫面。先記版本、數量、狀態與 hash,必要時才在受控的 debug 模式開更窄的紀錄。
如果 retriever timeout,root span 不能只標成 ERROR 然後結束。根 span 的失敗有使用者旅程意義,但 retrieval.search 也要有錯誤狀態,讓人知道錯在 dependency call,而不是 model 或 parser。
from opentelemetry.trace import Status, StatusCode
with tracer.start_as_current_span("retrieval.search") as span:
try:
documents = search_documents(question)
except TimeoutError as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, "retriever_timeout"))
raise
這個例外要在 API 邊界轉成預先定義的 outcome,例如 upstream_timeout。不要把原始 exception message 當 metric label;它常含網址、參數或 provider 內部資訊,而且每次字串可能不同。
retrieval.search 被標成 ERROR 後,root span(workflow.ask)不會自動跟著變 ERROR——子 span 失敗不代表整個 workflow 一定失敗(也許有 fallback),要不要讓 root span 反映失敗是應用層的決定:在 except TimeoutError 裡呼叫 root.set_status(...),同時讓 documents = [] 走 degraded path 繼續執行,而不是讓例外中斷整個 workflow。
分工是:retrieval.search 的 ERROR 描述「這個操作本身失敗了」,root span 的 ERROR 描述「使用者旅程層級是否受影響」——子操作失敗但有 fallback 補救時,root span 可以保持成功,只額外帶一個 degraded=true attribute。混為一談會讓錯誤率統計把「有 fallback 撐住的降級回應」也算進失敗案例,放大問題的嚴重程度。
一個 request 有三次 retry 呼叫時,有兩種合理建模:每次 attempt 都是一個 child span(可比較各次延遲與錯誤,資料量大);或是一個 retrieval.search span 加上 retry_count=2(資料少細節少)。選哪個不重要,重要的是全服務一致——若 A service 每次 retry 做 span、B service 只記總次數,跨服務的 latency comparison 就不再是同一個單位。先記 retry_count 即可;當 retry 本身成為調校對象,再展開成子 span。
parent-child 關係假設子 span 在父 span「還開著」時啟動,兩者生命週期有重疊。但把使用者問題丟進訊息佇列由背景 worker 非同步處理時,發送 request 早已回應結束、span 也關閉了,硬塞進「子 span」在語意上是錯的。OpenTelemetry 為此定義了 span link,讓兩個沒有父子關係、但邏輯上相關的 span 互相參照:
from opentelemetry.trace import Link
producer_span_context = trace.get_current_span().get_span_context()
# consumer 端(背景 worker,可能在數分鐘後才執行):
# 開一個新的 root span,用 link 指回 producer 端的 span
with tracer.start_as_current_span(
"worker.process_queued_question",
links=[Link(producer_span_context)],
) as worker_span:
process_question(message.payload)
這在 AI workflow 裡比想像中常見:多 agent 派工給非同步 agent、批次評估(Day 20 的 quality budget)等「先收下請求、稍後才處理」的佇列模式,都適合用 span link 而非硬湊 parent-child。判斷準則:兩個 span 生命週期有沒有重疊——有重疊用 parent-child,沒有用 span link。
retrieve_documents 若實際上是同時查詢多個向量索引 shard 再合併結果,這是③段提過的 fan-out 情境,也是 Day 18 tail latency 討論的具體落地場景:
retrieve_documents (INTERNAL, 父層彙總 span)
├─ shard_query[0] (CLIENT,平行執行)
├─ shard_query[1] (CLIENT,平行執行)
├─ shard_query[2] (CLIENT,平行執行)
└─ merge_results (INTERNAL)
父層 span 的耗時理論上該等於「等最慢的 shard 完成」加上合併時間,而不是所有 shard 耗時的總和——序列呼叫的子 span 依序排開,平行呼叫的子 span 在時間軸上重疊。若視覺化工具顯示這幾個 shard_query span 前後排開而非重疊,代表程式碼裡的「平行」呼叫其實意外變成序列執行——這是 trace 能抓到、metrics 只會告訴你「變慢了」卻看不出原因的典型案例。
Prometheus 提供幾種 metric 型別,選錯型別最常見的後果不是報錯,而是查出一個看起來合理、實際上錯誤的數字:
Counter — 只會遞增(重啟才會歸零),適合「累計發生次數」
例:ask_requests_total、bytes_sent_total
查詢時搭配 rate()/increase(),不要直接比較絕對值
Gauge — 可增可減,代表「當下的狀態值」
例:active_connections、queue_depth、gpu_memory_used_bytes
查詢時直接讀值即可,rate() 對 gauge 沒有意義
Histogram — 把觀測值分進預先定義的 bucket,用來算分位數
例:ask_request_duration_seconds
查詢時用 histogram_quantile(),bucket 邊界要事先訂好
Prometheus 還有一種較少被提到的型別叫 Summary,功能上和 Histogram 一樣是為了算分位數(P50、P95、P99),但實作方式完全不同,這個差異決定了它們該用在什麼情境:
Histogram:
在 client 端把觀測值分進預先定義的 bucket(例如 <100ms、<500ms、<1s……)
Prometheus server 端才用 histogram_quantile() 做內插計算分位數
→ 可以跨多個實例(多個 Pod)做聚合:sum(rate(..._bucket[5m])) by (le)
→ 分位數是「內插估算」,精確度取決於 bucket 邊界設得好不好
Summary:
在 client 端就直接計算好分位數(用 sliding window 演算法)
Prometheus server 端只是把算好的分位數存起來
→ 分位數更精確(不是內插估算)
→ 但無法跨實例聚合:Pod A 的 P95 和 Pod B 的 P95 沒辦法合併算出「整體 P95」
這個差異在多副本部署下格外關鍵:/ask 通常跑好幾個 Pod,事故排查關心的是「整個服務」的 P95。Summary 的分位數天生無法跨實例合併(數學上「多組分位數的平均」不等於「合併後資料的分位數」),這也是為什麼多數 production 場景會選 Histogram——即使只是內插估算,能跨實例正確聚合比單一實例算得更精確更重要。
常見錯誤還有:把「目前排隊中的 request 數量」做成 Counter(該用 Gauge);把 Histogram bucket 邊界設得太寬鬆,導致 histogram_quantile() 內插誤差大到失去意義——bucket 邊界要貼近服務實際延遲分布來設計。
counter 很適合記完成結果。以 /ask 為例,先限制 outcome:
from prometheus_client import Counter, Histogram
ASK_REQUESTS = Counter(
"ask_requests_total",
"Completed /ask requests",
labelnames=("route", "outcome", "workflow"),
)
ASK_DURATION = Histogram(
"ask_request_duration_seconds",
"End-to-end /ask duration",
labelnames=("route", "workflow"),
)
使用時只傳列舉值:
ASK_REQUESTS.labels(
route="/ask",
outcome="upstream_timeout",
workflow="policy-rag",
).inc()
不要做這件事:
# 錯誤示範:每個請求都長出新的 time series。
ASK_REQUESTS.labels(
route="/ask",
outcome="upstream_timeout",
workflow="policy-rag",
request_id=request_id,
user_id=user_id,
prompt=question,
).inc()
即使這段程式在本機跑得動,也不代表可以進 production。cardinality 問題的可怕之處在於它常不是馬上報錯;它會讓 Prometheus 記憶體、查詢時間與儲存量漸漸上升,直到故障時連監控系統自己也變慢。
這不是憑空想像的風險。多個 SRE 團隊公開寫過同一種失效形狀:有人把 user_id 或 request_path 加進既有 counter,本機測試流量小看不出異常,上線後時間序列數以指數速度增長,Prometheus 被 OOMKill,Grafana 面板整片空白。有案例記錄零售平台在 Black Friday 流量高峰遇到這個形狀:高基數 label 讓時間序列爆炸,監控系統在最關鍵的 90 多分鐘裡完全失效,工程師形容那是「flying blind」;修復本身只花 10 分鐘的 relabel 設定,代價卻是流量壓力最大的那 1.5 小時完全沒有可觀測性——讓監控系統本身故障,反而失去了最需要的告警能力。Last9:High Cardinality in Prometheus | ADHDecode:Cardinality Explosion
metric_relabel_configs:最後一道防線,不是第一道零售平台那個案例裡,修復手段是用 metric_relabel_configs 砍掉肇禍的 label,只花十分鐘完成。它是 Prometheus scrape 設定裡的一段規則,能在資料寫入 TSDB「之前」丟棄符合條件的樣本或欄位:
scrape_configs:
- job_name: policy-api
static_configs:
- targets: ["policy-api:8000"]
metric_relabel_configs:
- source_labels: [user_id]
regex: ".+"
action: labeldrop
target_label: user_id
這段設定能在事故當下快速止血——不用重新部署,只要改 scrape config 就能砍掉爆炸中的 label。但它終究是「最後一道防線」:等到需要動用它,代表 cardinality 審查已在更早的環節被繞過。⑯ 段「寫一份 label cardinality 規範,在新功能上線前就對照檢查」,指的正是要在這裡派上用場之前就把問題擋下來。
高基數問題容易被低估,直覺上「多加一個 label」只是多了「一種」欄位,但時間序列數量是各 label 可能值的乘積,不是加總:
只有 route × outcome:
route 有 5 種、outcome 有 3 種
→ 5 × 3 = 15 條時間序列
加上 workflow(10 種):
5 × 3 × 10 = 150 條時間序列
再加上 user_id(10 萬個活躍使用者):
5 × 3 × 10 × 100,000 = 1.5 億條時間序列
這正是「乘積而非加總」的可怕之處:前三個維度加起來才 150 條,只要多加一個十萬基數的欄位,整個 metric 就從「Prometheus 輕鬆處理」跳到「單一 metric 吃光一台 server 的記憶體」。cardinality 審查不能只憑直覺,要實際估算欄位在 production 可能出現幾種值,再乘上已有 label 的組合數。
先問「最近五分鐘 /ask 的 timeout 比例有沒有異常?」:
sum(rate(ask_requests_total{route="/ask",outcome="upstream_timeout"}[5m]))
/
sum(rate(ask_requests_total{route="/ask"}[5m]))
結果若升高,下一步不是把 query 再加上 request_id,因為 metric 根本不該有它。正確下一步是從告警時間、route、outcome 轉到 log,找 dependency 和版本;再以 trace_id 或 request_id 打開少量代表性 trace。
把這條 PromQL 接上告警規則,才是這條查詢真正發揮作用的地方——沒接上告警的查詢,只是一張需要有人記得去看的圖表:
groups:
- name: ask-timeout
rules:
- alert: AskUpstreamTimeoutRatioHigh
expr: |
sum(rate(ask_requests_total{route="/ask",outcome="upstream_timeout"}[5m]))
/
sum(rate(ask_requests_total{route="/ask"}[5m]))
> 0.05
for: 5m
labels:
severity: page
annotations:
summary: "/ask upstream_timeout ratio 超過 5%,持續 5 分鐘"
runbook_url: "https://internal-wiki/runbooks/ask-timeout"
for: 5m 要求異常狀態持續滿五分鐘才觸發,用意是過濾掉單次抖動,呼應 Day 14 multiwindow, multi-burn-rate 概念。runbook_url 這個 annotation 也不是裝飾,它讓值班工程師收到告警瞬間就能一鍵跳到⑮段那份最小 runbook。
某些 metrics/trace backend 支援 exemplar:在 histogram bucket 或 counter sample 附上一條 trace 連結,讓 Grafana 從延遲尖峰直接跳到代表性 trace。這能縮短點擊路徑,但不會讓 trace 變成全量資料,也不能讓你把敏感 prompt 寫進 metric label。
使用 exemplar 前要確認三件事:
沒有這三項,exemplar 只是看起來很炫的空連結。
延續①段開頭的場景——值班工程師打開 Grafana 該先看到什麼?以下是教學用的 panel 配置形狀,示範三種訊號如何在同一塊儀表板上分層呈現:
Row 1(Metrics,永遠置頂,掃一眼判斷範圍)
┌─────────────────────┬─────────────────────┐
│ /ask timeout ratio │ /ask P50/P95/P99 │
│ (5m rate) │ latency │
└─────────────────────┴─────────────────────┘
Row 2(Logs,摺疊預設收起,需要時展開)
┌───────────────────────────────────────────┐
│ Loki panel:{service_name="policy-api"} │
│ | json | outcome!="success" │
└───────────────────────────────────────────┘
Row 3(Traces,僅提供跳轉連結,不常駐渲染完整 UI)
┌───────────────────────────────────────────┐
│ "Open trace explorer" data link │
│ (帶入目前 dashboard 的時間範圍與 service) │
└───────────────────────────────────────────┘
這個順序不是美感考量,直接對應⑪段的排查優先順序:metrics 永遠置頂,因為它先回答「範圍多大」;logs 預設收起但隨時能展開;trace 只留跳轉連結而不常駐渲染,因為完整的 trace 視覺化通常要挑選特定一條才有意義,硬塞進 dashboard 只會占版面看不出重點。
以下事件完全虛構。目的不是背 SOP,而是練習每種訊號在不同時刻接手。
10:00 /ask 的 upstream_timeout ratio 升高。
10:02 on-call 確認 traffic 沒有歸零,延遲 P95 同時上升。
10:04 Loki 顯示 timeout 集中在 dependency=retriever。
10:06 同一段時間的 trace 顯示 retrieval.search 佔 78% 總耗時。
10:08 log 顯示 workflow.version=v3 的請求開始增加。
10:10 對照 deployment_id,發現 v3 把 retrieval top_k 從 3 改成 12。
10:12 rollback 或降低 top_k;持續看 metric 是否恢復。
第一步看 metrics,因為它先回答影響範圍。若 timeout 只是一筆 request,未必需要緊急 rollback;若是比例持續升高,才值得進下一層。
第二步看 logs,因為它能把錯誤分類成 retriever、model provider、tool 或 validation。此時不要急著開一條成功 trace;先找 timeout event 最多的時間段與版本。
第三步看 traces,因為它告訴你 retrieval.search 裡的哪個子段慢。若 trace 顯示等待 connection pool,而不是遠端 retrieval,修復方向會完全不同。
最後回 metrics 驗證復原。單一 trace 成功,只能證明某一次成功;恢復比例與延遲分布才說明使用者是否真的不再受影響。
這張時間軸的每一步都動用前段打好的基礎:10:04 篩 dependency=retriever 用的是⑧段的結構化事件命名;10:06 看出 retrieval.search 佔 78% 總耗時,靠的是⑨段的 span 拆分粒度;10:10 對照 deployment_id 找出版本改動,仰賴的是⑦段的欄位契約。沒有這些基礎,這張看似流暢的 12 分鐘時間軸實際上寸步難行。
下面這張表刻意寫得像真的會有人脫口而出的句子——現實裡犯這些錯的工程師都不笨,只是被單一訊號的視野局限住,順著眼前唯一看得到的資料做出看似合理的推論。
| 看到的訊號 | 草率結論 | 還缺哪個問題? |
|---|---|---|
| timeout ratio 上升 | model provider 壞了 | 哪個 dependency、哪個版本? |
| retriever timeout log | retriever 一定慢 | 它佔整體請求多少時間、是不是全量? |
| retrieval span 900 ms | 服務已經壞了 | 這是單一慢請求,還是 P95 整體上升? |
事故時最有價值的一句話通常不是「我看到錯誤」,而是「我已經用哪個訊號排除了什麼」。這能避免三個人同時盯同一張圖,卻沒有任何人確認使用者影響。
上面那份時間軸故意把 metrics → logs → traces 的順序寫清楚。若 on-call 一開始就直接開 Tempo 找一條 trace,大概率更慢——10:00 手上只有「timeout ratio 升高」這一個訊號,還不知道要找哪一筆 request,而 Tempo 搜尋框需要先給條件(時間範圍、attribute、duration 門檻),這些條件正是從 metrics 和 logs 篩出來的。跳過前兩步等於在不知道「要找什麼」時就翻一棵可能有幾千節點的樹,反而更容易迷路——三種訊號的順序本身就是一種漏斗,每一步都用上一步的結果縮小範圍。
反過來說,若已經確定是單一 request 的個案(例如客服回報),可以直接跳過 metrics、logs,從 request_id 打開對應 trace,因為「範圍」已經確定,不需要再靠 metrics 判斷是否是系統性問題。查詢順序永遠取決於「你現在已知什麼、還缺什麼」,不是一套僵化的 SOP。
讀者可在自己的 Day 21 DIY 專案完成下列練習。本文只提供設計與命令形狀,不會建立檔案、安裝套件、啟動容器或宣稱結果已驗證。
這個 DIY 是把①到⑪講的抽象規則,變成可以親眼確認的具體行為。跑一次成功請求、一次 timeout 請求,本質上是在對三個主張做實驗:
request_id 加進 label,跑十幾次不同請求,看 /metrics 輸出膨脹的速度——唯一能親眼看到「cardinality explosion 不會馬上報錯,但每一筆新 request_id 都在悄悄增加一條新時間序列」的方法。request_id 若沒有明確傳遞規則,會在某些邊界悄悄斷掉。 DIY 的 RequestContextMiddleware 示範了 ingress 層該做的事:讀取或產生 X-Request-ID、存進 request.state、回寫到 response header。跑一次「不帶 header」再跑一次「帶 header」,比對兩次的值分別是不是「自動產生的 UUID」與「原封不動回來的指定值」。這個 DIY 的價值不在「服務有沒有跑起來」,而在「三種訊號各自展現的行為,是否真的符合①到⑪講的規則」。
/ask 範例。每一步後面附上「這一步在驗證什麼」:
X-Request-ID,並回寫到 response header。 驗證⑦段「request_id 要有明確傳遞規則」——同一筆 request 不管走到哪,都能透過 request.state.request_id 取用同一個值。TimeoutError,不要靠真的把網路拔掉。 延續 Day 03 故障注入的精神:用受控 fixture 製造確定性失敗,才能保證每次重跑得到一致的觀測結果。event、request_id、dependency、outcome。 驗證⑧段「事件名稱要穩定、欄位要結構化」——檢查這行 log 能不能被 | json | outcome="timeout" 這種 LogQL 篩出來。request_id 在三種資料裡各自呈現什麼樣貌,且沒有一種資料能單獨回答另外兩題。以下命令的路徑與套件名稱要依讀者自己的 DIY README 調整:
cd Day21/DIY
uv sync
uv run uvicorn app.main:app --reload
另開一個 terminal,以你的測試 fixture 發送 request:
curl -i \
-H 'X-Request-ID: demo-42' \
-H 'Content-Type: application/json' \
-d '{"question":"fixture: retriever timeout"}' \
http://127.0.0.1:8000/ask
若你的服務不是 HTTP,保留同樣原則:進入點有 request ID、每個 dependency event 有結構化 outcome、整條 workflow 有可關聯的 trace。
成功時,你應能看到 /ask 成功 outcome 的 counter 增加、一筆 dependency_call_finished log,以及一條含 workflow 與 child span 的 trace。
timeout 時,counter 的 outcome="upstream_timeout" 增加;log 應可用 request_id="demo-42" 找到;trace 應顯示 retrieval.search 有錯誤或 timeout 狀態。若你的 error handler 改成回 200 + degraded answer,metric outcome 仍要反映產品定義,不能因 HTTP status 是 200 就自動寫 success。
即使程式碼完全照著文章的形狀寫,實際跑一次時仍有幾個坑值得先知道,因為它們是資料模型本身容易造成的誤會,而不是打錯字:
service_name 打錯、忘記加 environment)——Loki 沒有全文索引,選錯 label 等於一開始就沒選到正確的 log stream。先用最寬的 {service_name="..."} 確認 log 真的有進來,再逐步縮小條件。with block 結束後才被建立——這正是⑨段強調「手動開 span 要注意 context 邊界」的原因。--reload 模式跑 uvicorn,程式碼變動會觸發 reload,記憶體裡的 Counter 會被重置歸零——這是⑩段提過「counter 歸零可能只是被重啟」的真實案例。這些坑展示了「觀測系統看起來沒問題,但查詢方式或資料模型假設錯了」和「服務真的壞了」是完全不同的排查技能——呼應本文一路強調的「先確認資料契約,再談 dashboard」。
| 檢查 | 可接受結果 | 常見失敗 |
|---|---|---|
| request ID | response、log、trace 可關聯 | middleware 沒穿過 background task |
| metrics | label 只有受控小集合 | 把 request ID、prompt 放進 label |
| logs | 可依 event、outcome、dependency 篩選 | 欄位藏在自由文字 message |
| traces | root 與關鍵 workflow span 可見 | 只留下 root span,看不到依賴 |
| timeout fixture | 可穩定重現 | 靠不穩定的真實網路製造失敗 |
| 隱私 | raw prompt 與文件內容預設不輸出 | debug log 無限制留存 |
若任一項不成立,先修資料契約與 propagation,再加 dashboard。看板只能把已有的資料排列得漂亮,不能替缺少的關聯鍵變魔術。
metrics 通常是聚合後長期保存;logs 與 traces 的量則可能跟請求量一起長。這會逼你做取捨,但取捨必須寫下來。
先把三種訊號的成本結構攤開來看,因為它們對「流量成長」的反應方式並不一樣:
metrics 的成本:
儲存量 ≈ f(metric 數量 × label 基數 × 保留天數)
幾乎與「request 流量本身」無關 —— 這是 metrics 能長期便宜保留的原因
唯一的風險是 cardinality 失控(本文 ⑩ 段的核心警告)
logs 的成本:
儲存量 ≈ f(request 流量 × 每筆 log 大小 × 保留天數)
與流量線性成長,流量翻倍,儲存成本也大致翻倍
可用壓縮、取樣、欄位裁剪去壓低係數,但成長趨勢改不掉
traces 的成本:
儲存量 ≈ f(request 流量 × span 數量 × attribute 大小 × sampling rate × 保留天數)
維度最多,也最容易失控——sampling rate 是主要的成本槓桿
這張對比說明了為什麼「metrics 保留一年、logs 保留 30 天、traces 保留 7 天」是業界常見的搭配,而不是隨便訂的數字:越靠右邊的訊號,單位時間的儲存成本越高,保留期限自然要越短才撐得住預算。
一種務實的起點如下:
成功的低延遲請求:低比例抽樣。
錯誤請求:提高抽樣比例,或由 tail sampling 保留。
高延遲請求:依 latency threshold 保留。
安全事件:依資安 policy 留存,且採更嚴格存取控制。
原始 prompt/document:預設不收集;例外需有 owner 與到期日。
這不是通用 production 設定。實際比例受流量、成本、法律、incident response 需求和後端能力影響。重要的是:當 trace search 找不到一筆 request,runbook 要明說「可能被 sampling 丟棄」,不能把找不到誤解成沒有發生。
head sampling 在入口就決定要不要保留 trace,成本低,但可能剛好丟掉後來才變慢或失敗的 request。tail sampling 等收集到完整 trace 再依錯誤、延遲或 attribute 決定,對保留異常案例更有用;代價是 collector 需要暫存資料,也要承擔更高的記憶體與壓力設計。
不要只寫「production 用 tail sampling」就結束,還要回答:collector 滿載時怎麼處理?是否會丟最需要的錯誤 trace?drop 的統計有沒有 metrics?
這裡有一個值得記住的遞迴性:observability pipeline 自己也是一個系統,一樣需要被觀測。collector 該替自己的 drop rate、queue depth、export 失敗率暴露 metrics——若 collector 悄悄丟棄了 30% 的 trace,卻沒有任何訊號告訴你,團隊會把「trace 找不到」誤判成「這個 request 沒有真的發生問題」,而不是「pipeline 本身正在過載」,而且比⑭段反例二更容易被忽略,因為沒有人會想到要監控「監控系統自己」。
head sampling 的代價可以講得更具體:10% 抽樣率下,一次性、非重複的失敗有九成機率完全沒被保留下來——抽樣決定通常在請求一開始就要做,那時候系統還不知道這個請求最後會不會失敗,等真的失敗了才想回頭找 trace 已經來不及。比較務實的起點是把「保留所有錯誤事件」與「用較低比例抽樣一般 trace」分開設定,並在錯誤事件裡至少留下 operation 名稱、部署版本、retry 次數這類「即使 trace 不在也還有用」的欄位。OneUptime:Tune Sentry Error and Trace Sampling While Preserving Rare Failures
如果 logs 保留 30 天、traces 保留 7 天、evaluation artifact 保留 90 天,事故在第 14 天才被回報時,可能只剩 log 沒有 trace。這不一定錯,但要是刻意的服務承諾。
把 retention 寫進資料治理表:
| 資料 | 目的 | 建議初始存取者 | 期限要問的問題 |
|---|---|---|---|
| 聚合 metrics | 告警與趨勢 | on-call、SRE | 是否需要跨季比較? |
| 結構化 logs | 事件鑑識 | on-call、服務 owner | 是否含識別欄位? |
| traces | 路徑與效能診斷 | SRE、服務 owner | sampling 後還能回答 incident 嗎? |
| AI evaluation artifact | 品質回歸 | AI owner、reviewer | 是否含原始 prompt 或文件? |
表格沒有替你決定天數,卻能逼團隊把「誰能看、看多久、為什麼要留」講完整。
寫在治理表裡的天數,和儲存後端實際執行的保留策略未必永遠一致——例如 Loki 的 retention 是依 tenant 或 stream 設定的,若有人不小心把新服務的 log 送進 retention 設得更長的既有 tenant,這筆資料的實際存活時間會悄悄超出治理表承諾的天數,卻沒有人主動發現。比較保守的做法是定期稽核,確認儲存後端裡最舊的資料時間戳記沒有超過治理表訂下的期限——重點是「承諾」和「實際行為」之間要有定期比對機制,不能設定完就假設它永遠正確執行。這類違反通常要到資料外洩調查或法遵稽核時才會浮現,格外需要主動驗證。
以下是教學用的設定形狀,示範「錯誤全留、成功低比例抽樣」在 OpenTelemetry Collector 的 tail sampling processor 裡大致長什麼樣子。讀者要自行對照自己 collector 的版本語法:
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: keep-all-errors
type: status_code
status_code:
status_codes: [ERROR]
- name: keep-slow-requests
type: latency
latency:
threshold_ms: 800
- name: sample-the-rest
type: probabilistic
probabilistic:
sampling_percentage: 10
邏輯順序很重要:先無條件保留所有錯誤與高延遲請求,剩下的「正常、快速」請求才套用低比例抽樣——這正是本段開頭那份「務實起點」清單的落地形式,用 policy 順序把「值得全留」和「可以抽樣」分開處理。
好處很誘惑:事故一發生,所有輸入都在。代價是 API token、內部文件、使用者問題與模型輸出可能一起被複製到多個後端,而且 debug log 常被延長 retention,因為「以後可能會用到」。
這種寫法在程式碼裡通常長這樣,而且第一眼看起來完全無害:
# 反例:出於「除錯方便」的好意,把整個 request 序列化進 log。
try:
answer = await answer_question(request)
except Exception as exc:
logger.error(
"ask failed",
extra={
"request_id": request.state.request_id,
"request_body": request_body.model_dump(), # 含使用者原始問題、內部參數
"exception": str(exc),
},
)
raise
問題不在「這行程式碼會不會壞掉」——它會正常執行,log 也會正確寫出。問題在於它把「除錯方便」的責任偷偷轉嫁給資料治理:這筆 log 一旦寫進 Loki,任何有讀取權限的人都能看到完整的使用者輸入,而且很可能被歸進保留期限最長的「debug log」分類,因為沒有人敢刪除「以後可能會用到」的東西。
改法是先記 request ID、schema version、payload size、分類結果與必要 hash;需要原始內容時,走受控的 incident evidence 流程,而不是把它變成所有日常 log 的預設欄位:
logger.error(
"ask failed",
extra={
"request_id": request.state.request_id,
"schema_version": request_body.schema_version,
"payload_size_bytes": len(request_body.model_dump_json()),
"exception_class": type(exc).__name__,
},
)
這個版本回答得了「哪個 request、什麼大小、什麼類型的例外」,卻不會把問題原文複製進日常可讀的後端。真的需要原文時,走一條有審核紀錄的 incident evidence 流程,而不是讓每一次錯誤都預設留下完整內容。
若 trace 採樣 10%,而且成功與失敗沒有同樣機率被保留,trace search 的成功率不等於服務成功率。甚至錯誤 trace 被保留得更多時,畫面看起來會比真實失敗率更糟。
用具體數字說明這個落差:假設真實失敗率是 0.5%,採樣策略是「成功請求 10% 抽樣、失敗請求 100% 全留」(⑬段建議的常見起點)。10000 次請求裡,真實情況是 9950 次成功、50 次失敗;但採樣後留下的 trace 是 995 次成功、50 次失敗——用「trace search 裡失敗 trace 的比例」去算,會得到 50 / (995 + 50) ≈ 4.8%,是真實失敗率的將近十倍。這不是採樣策略設計錯誤,這正是它該有的行為(優先保留錯誤案例);錯誤的是拿這個被刻意扭曲過的比例,去回答「服務現在的可靠度是多少」。
SLI 應從完整的 request counter、可靠 access log 或明確定義的事件流算。trace 是解釋代表案例,不是替代全量計數器。
trace_id 放進 Prometheus 和 Loki label看起來可以快速跳轉,實際上會把每筆 request 變成新 stream 和新 time series。可觀測性系統先被自己觀測到掛掉,這種諷刺一點也不好笑。
# 反例:以為把 trace_id 設成 label 能加速跳轉,實際上每個 trace_id 都會開一條新的 Prometheus 時間序列。
ASK_REQUESTS = Counter(
"ask_requests_total",
"Completed /ask requests",
labelnames=("route", "outcome", "trace_id"), # 危險:trace_id 基數等於 request 數
)
# Loki 端的對應反例:把 trace_id 設成 stream label(而非 JSON 欄位),
# 每個不同的 trace_id 都會開一條新的 log stream,Loki 的 index 會跟著爆炸。
{service_name="policy-api", trace_id="4bf92f3577b34da6a3ce929d0e0e4736"}
兩段程式碼看起來都只是「多加一個方便查詢的欄位」,實際後果卻是 Prometheus 時間序列數量、Loki stream 數量各自以「每一筆 request 一條」的速度增長——這正是⑩段 cardinality 乘法效應,只是連 trace_id 這個天生高基數的欄位都被誤放進不該放的位置。
保留低基數 label,使用 JSON 欄位、trace link 或 dashboard data link 做跳轉。跳轉多點一次沒關係;監控後端活著比較重要。
這個反例不是資料設計錯誤,而是使用習慣的錯誤——卻同樣常見。許多 dashboard 用綠/黃/紅快速標示狀態,方便一眼掃過整排面板,但顏色背後的門檻值往往是很久以前設定的,隨流量成長早就不合時宜——面板綠色,不代表數字真的健康,只代表還沒跨過那條可能過時的線。更隱蔽的是 dashboard 顯示的是預先算好的聚合值而非即時查詢,事故當下可能把「面板剛好沒更新」誤判成「系統真的沒事」。
養成習慣:事故排查時至少對一個關鍵面板點進去看原始查詢語句,確認「這個數字代表什麼」和查詢實際在算的東西一致——不會花超過一分鐘,卻能避免把「儀表板沒有紅」誤讀成「服務沒有問題」。
本文 ⑨ 段示範的都是手動開 span,容易讓人誤以為每個 span 都得靠開發者一行一行寫。實務上多數框架邊界的 span 該交給 auto-instrumentation 處理:
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
FastAPIInstrumentor.instrument_app(app) # 自動幫每個 route 開 SERVER span
HTTPXClientInstrumentor().instrument() # 自動幫每個 outbound HTTP 呼叫開 CLIENT span
PsycopgInstrumentor().instrument() # 自動幫每個資料庫查詢開 CLIENT span
這三行大致對應⑨段樹狀圖裡 /ask(root)、call_model、未來接上 PostgreSQL 查詢的部分。手動開 span 該保留給 auto-instrumentation 覆蓋不到、卻對業務語意重要的邊界,例如 retrieval.search 這種框架本身不知道值得獨立看待的內部函式呼叫。能自動涵蓋的交給 instrumentation library,該手動標記的才手動寫。
你不需要先寫一本事故百科全書。先讓 /ask 的 on-call 能照這段完成第一次分類:
1. 確認 alert 的時間窗、route、outcome 與使用者影響。
2. 用 metrics 判斷比例、延遲與 traffic 是否仍異常。
3. 在 Loki 以 service、event、outcome 篩選,再看 dependency 與版本。
4. 以 request_id 或 trace_id 打開一條錯誤或高延遲 trace。
5. 判斷是服務、依賴、資源、workflow 版本或資料品質問題。
6. 採取 rollback、降級、擴容、關閉 tool 或升級 incident 的既定動作。
7. 回到 metrics 確認使用者層指標恢復;記錄未知處,不假裝已找到根因。
這份 runbook 有意把「品質問題」和「HTTP 故障」都放進分類。若 API 可用但 Day 20 的 quality budget 持續惡化,metrics、logs、traces 仍要能指出 prompt version、retrieval index version 與 evaluator outcome;只是是否 rollback,要由產品承諾與 release policy 判斷,不能由一條 HTTP 200 決定。
值班工程師常有一種心理壓力:覺得「必須交出一個根因」才算完成任務,即使證據只夠支持到「可能是這個原因」,也會被誘使直接寫成定論,之後很難再有人回頭挑戰它。
Google SRE Book 的 postmortem 文化強調「blameless」與「誠實記錄不確定性」,原因正是這個機制——寫死「根因是 X」卻證據不足,比誠實寫「目前證據指向 X,但 Y 尚未排除」更危險,前者會讓團隊誤以為問題已解決,直到同一類故障下次用不同樣貌重演。三種訊號的證據終究有限,runbook 該鼓勵誠實標注邊界,而不是逼出一個看似完整、實際拼湊出來的故事。
這份 runbook 每一步都對應本文前段的具體規則,本來就該是把①到⑭學到的東西收斂成值班時真正會翻開的那一頁。也因此該隨服務演化持續更新:新增一個 dependency,第 3 步的篩選欄位要跟著擴充;換了 trace backend,第 4 步的查詢方式也要同步修改。當成一次性交付物寫完就不管,它會脫節成看似完整、實際誤導新人的過時文件——和 ⑭ 段反例四「只信任過時的 dashboard 門檻」是同一種風險,只是換了一種載體。
如果這是要上線的 production system,我會做三件事:先寫一份 label cardinality 規範,明確列出 metric label 允許放哪些欄位、禁止放哪些欄位,讓新功能上線前就能對照檢查;再把 request_id(或 trace 的 trace_id)當成貫穿 log 與 trace 的關聯慣例,寫進 logging 中介層而不是靠每個開發者自己記得加;最後替 prompt、使用者輸入這類敏感欄位訂出保留期限與存取控制,而不是預設它們能無限期留在任何一種可觀測性後端。metrics、logs、traces 各自解決一種問題,但敏感資料的治理規則要統一。
這三件事有一個共通點:它們都是「在新功能上線前擋下來」的檢查,而不是「事故發生後補救」的動作。cardinality 規範該在 code review 就被強制檢查(甚至寫成 CI linter,掃過新增的 .labels() 呼叫);request_id 的傳遞該是框架層級的中介層,讓開發者不需要特別記得就自動擁有這個能力;敏感欄位的保留政策也該是 logging library 或欄位 allowlist 層級的預設值。把治理規則往左移(shift left)到開發階段而非事故後補救,是觀測性工程和其他可靠度工程領域共享的原則——修一個還沒上線的 bug,永遠比修一個已經造成事故的 bug 便宜。
這三件事也回答了①段開頭的問題——「availability 掉了,第一眼要點開哪個面板?」三件治理規則做對了,這個問題會變得不再重要,因為不管先點哪個面板,你都能順著關聯鍵走到下一個訊號,而不會卡在某個看不懂、連不回去、或根本不該存在的斷點上。
觀測不是資料量競賽;資料要能把告警帶到下一個排查動作。今天先確認 metrics、logs、traces 各自的邊界,明天用 Golden Signals、RED、USE 決定 dashboard 該從哪裡看起。
整條路線是一個縮小版的「Build → Trace → Break → Measure」:先建立三種訊號(Build),確保它們能串成同一份現實(Trace),刻意打破單一訊號的假設去看盲區在哪(Break),最後量化成本與可靠度的取捨(Measure)。明天的 Golden Signals、RED、USE 要處理的,其實是這套訊號基礎設施之上「該優先看哪幾個數字」——這層資料契約沒打好,明天再漂亮的 dashboard 設計,底層接的仍會是一堆連不起來、信不過的資料。
在移動到下一篇之前,留三個問題給自己(或團隊)現有的服務做個快速體檢,答不上來的地方,就是這篇文章該回頭重讀的段落:
1. 我的 metric label 裡,有沒有任何欄位的可能值數量會隨使用者數或請求量成長?
(若有,回頭看 ⑩ 段的 cardinality 數學)
2. 我的 log 能不能靠 request_id 或 trace_id 準確連回對應的 trace?
還是連回去的字串「看起來對」,但其實踩到了 ⑦ 段那四種失效原因之一?
3. 我的 trace span 樹,展開後是不是每一層都值得存在?
還是有些 span 只是因為「順手」而開,卻沒有人會因為它慢了、錯了而做出不同的事?
這三個問題沒有標準答案,答案會隨著服務規模與團隊經驗持續演化——但持續問這三個問題,比一次性地把 dashboard 做得漂亮更重要。
下一篇會接著處理「同樣一套 metrics、logs、traces,dashboard 的第一眼該放哪些指標」。
這篇是 Learning SRE for the AI Era 系列的一部分。
我會從 SRE 的服務可靠性基礎開始,逐步探索當系統加入 LLM、RAG、Agent 與 GPU Infrastructure 後,如何讓 AI 系統不只可用,也能被觀測、評估、控制成本並安全演進。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.