iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 34 篇

Day 21(下)|Metrics、Logs、Traces:同一件事,三種問題

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:trace 要拆到能做決定、metrics label 要受控、sampling 與 retention 要寫進治理表——這三件事沒做對,再漂亮的 dashboard 底層接的都是連不起來的資料。

承接上文

上篇談了 metrics、logs、traces 三種訊號各自的邊界,AI workflow 多出來的路徑與敏感欄位,並用一個最小 DIY 練習定義了貫穿三種訊號的關聯鍵:request_id、trace_id、span_id 是三個生命週期不同的概念,硬合併成一個字串只會讓查詢更難維護。這篇接著把關聯鍵落地成可執行的 span 設計、metrics 治理與排查流程。

⑨ 一條 trace 要拆到能做決定,而不是拆到看不完

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。只是一個字串格式化,通常不用。

SpanKind 在說什麼,常被當成裝飾忽略

上面的樹狀圖裡,每個節點後面標了 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,後端分不清哪段是本地運算、哪段是網路呼叫,也就畫不出跨服務依賴圖。

context propagation:span 怎麼知道自己是誰的孩子

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。

手動 span 的教學形狀

這是依 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 模式開更窄的紀錄。

error 記在正確的 span

如果 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 內部資訊,而且每次字串可能不同。

span 的錯誤狀態要往上冒泡,但不是自動的

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 撐住的降級回應」也算進失敗案例,放大問題的嚴重程度。

retries 會改變 trace 的讀法

一個 request 有三次 retry 呼叫時,有兩種合理建模:每次 attempt 都是一個 child span(可比較各次延遲與錯誤,資料量大);或是一個 retrieval.search span 加上 retry_count=2(資料少細節少)。選哪個不重要,重要的是全服務一致——若 A service 每次 retry 做 span、B service 只記總次數,跨服務的 latency comparison 就不再是同一個單位。先記 retry_count 即可;當 retry 本身成為調校對象,再展開成子 span。

span link:當關係不是父子,而是「相關」

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。

平行呼叫(fan-out)該怎麼建模 span

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 只會告訴你「變慢了」卻看不出原因的典型案例。

⑩ Metrics 要能告警,不能承擔鑑識工作

Counter、Gauge、Histogram:選錯型別會讓查詢語言騙你

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 邊界要事先訂好

Histogram 和 Summary:都算分位數,取捨完全不同

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 規範,在新功能上線前就對照檢查」,指的正是要在這裡派上用場之前就把問題擋下來。

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 的組合數。

一個由 metric 出發的 PromQL 問題

先問「最近五分鐘 /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。

Exemplar 是一條可選的橋,不是資料治理豁免

某些 metrics/trace backend 支援 exemplar:在 histogram bucket 或 counter sample 附上一條 trace 連結,讓 Grafana 從延遲尖峰直接跳到代表性 trace。這能縮短點擊路徑,但不會讓 trace 變成全量資料,也不能讓你把敏感 prompt 寫進 metric label。

使用 exemplar 前要確認三件事:

  • backend 是否真的保留並顯示它,而不是 exporter 接收後丟掉。
  • sampling 策略是否讓高錯誤、高延遲案例仍有足夠的 trace。
  • 跳轉後的 trace 權限是否比 metrics dashboard 更嚴格。

沒有這三項,exemplar 只是看起來很炫的空連結。

一個 Grafana panel 的最小組合

延續①段開頭的場景——值班工程師打開 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 只會占版面看不出重點。

⑪ 從 alert 到根因:一個 12 分鐘的排查練習

以下事件完全虛構。目的不是背 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。

⑫ 今日 DIY:把查詢路徑寫成可重跑的實驗

讀者可在自己的 Day 21 DIY 專案完成下列練習。本文只提供設計與命令形狀,不會建立檔案、安裝套件、啟動容器或宣稱結果已驗證。

這個實作在驗證前面講的哪些論點?不用真的跑,先看懂它在測什麼

這個 DIY 是把①到⑪講的抽象規則,變成可以親眼確認的具體行為。跑一次成功請求、一次 timeout 請求,本質上是在對三個主張做實驗:

  • (②③段)三種訊號的角色分工是可觀測的行為差異。 counter 增加是瞬間的;log 是離散的一行;trace 要等整個 request 所有 span 關閉才完整——請求還沒結束時查 trace,看到的可能是不完整的樹,這不是 bug,是資料模型本身的特性。
  • (⑩段)metrics label 的基數必須受控,而這件事不會在本機測試時報錯。 值得動手做的實驗是故意把 request_id 加進 label,跑十幾次不同請求,看 /metrics 輸出膨脹的速度——唯一能親眼看到「cardinality explosion 不會馬上報錯,但每一筆新 request_id 都在悄悄增加一條新時間序列」的方法。
  • (⑦段)request_id 若沒有明確傳遞規則,會在某些邊界悄悄斷掉。 DIY 的 RequestContextMiddleware 示範了 ingress 層該做的事:讀取或產生 X-Request-ID、存進 request.state、回寫到 response header。跑一次「不帶 header」再跑一次「帶 header」,比對兩次的值分別是不是「自動產生的 UUID」與「原封不動回來的指定值」。

這個 DIY 的價值不在「服務有沒有跑起來」,而在「三種訊號各自展現的行為,是否真的符合①到⑪講的規則」。

前置條件

  • 一個可啟動的 FastAPI /ask 範例。
  • Prometheus scrape endpoint,或等價的 metrics endpoint。
  • 能接收 JSON stdout log 的 Loki/Alloy pipeline。
  • 已設定的 OpenTelemetry tracer provider 與可查詢的 trace backend。
  • 一個不含真實使用者資料的 timeout fixture。

建議實作順序

每一步後面附上「這一步在驗證什麼」:

  1. 在 HTTP ingress 產生或接收 X-Request-ID,並回寫到 response header。 驗證⑦段「request_id 要有明確傳遞規則」——同一筆 request 不管走到哪,都能透過 request.state.request_id 取用同一個值。
  2. 在 retriever fixture 產生固定的 TimeoutError,不要靠真的把網路拔掉。 延續 Day 03 故障注入的精神:用受控 fixture 製造確定性失敗,才能保證每次重跑得到一致的觀測結果。
  3. 在 dependency 結束處寫一筆 JSON log,帶 event、request_id、dependency、outcome。 驗證⑧段「事件名稱要穩定、欄位要結構化」——檢查這行 log 能不能被 | json | outcome="timeout" 這種 LogQL 篩出來。
  4. 在 workflow、retrieval、model、validation 邊界建立 span。 驗證⑨段「span 拆到能做決定,而不是拆到看不完」——數一下自己建了幾個 span,若每個 helper function 都想開一個,代表判斷標準沒有被落實。
  5. 在 HTTP response 完成後增加受控 outcome 的 counter 與 duration histogram。 驗證⑩段「metrics label 只能是受控小集合」——想一下之後加新欄位進 label 會不會引入高基數風險。
  6. 以同一個 fixture 跑一次成功、一次 timeout,再依序查 metric、log、trace。 這是整個 DIY 要收斂的問題:同一個 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。

實際跑的時候,最容易踩到的坑不是程式碼寫錯

即使程式碼完全照著文章的形狀寫,實際跑一次時仍有幾個坑值得先知道,因為它們是資料模型本身容易造成的誤會,而不是打錯字:

  • 「明明 log 有寫,Loki 卻查不到。」 常見原因是查詢時用錯 label(service_name 打錯、忘記加 environment)——Loki 沒有全文索引,選錯 label 等於一開始就沒選到正確的 log stream。先用最寬的 {service_name="..."} 確認 log 真的有進來,再逐步縮小條件。
  • 「trace 只看到 root span,子 span 不見了。」 常見原因是 tracer provider 在多次請求間沒正確保留 context,或子 span 在 with block 結束後才被建立——這正是⑨段強調「手動開 span 要注意 context 邊界」的原因。
  • 「metrics 的數字對不上實際跑的次數。」 若用 --reload 模式跑 uvicorn,程式碼變動會觸發 reload,記憶體裡的 Counter 會被重置歸零——這是⑩段提過「counter 歸零可能只是被重啟」的真實案例。
  • 「timeout fixture 沒有穩定觸發。」 若靠字串比對決定要不要模擬 timeout,字串打錯一個字元或大小寫不一致就會讓 fixture 靜默走成功路徑,真正上 production 前通常會換成環境變數或明確的測試專用參數。

這些坑展示了「觀測系統看起來沒問題,但查詢方式或資料模型假設錯了」和「服務真的壞了」是完全不同的排查技能——呼應本文一路強調的「先確認資料契約,再談 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。看板只能把已有的資料排列得漂亮,不能替缺少的關聯鍵變魔術。

⑬ sampling、retention 與成本:看不見的 trace 不叫沒有問題

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 丟棄」,不能把找不到誤解成沒有發生。

tail 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,這筆資料的實際存活時間會悄悄超出治理表承諾的天數,卻沒有人主動發現。比較保守的做法是定期稽核,確認儲存後端裡最舊的資料時間戳記沒有超過治理表訂下的期限——重點是「承諾」和「實際行為」之間要有定期比對機制,不能設定完就假設它永遠正確執行。這類違反通常要到資料外洩調查或法遵稽核時才會浮現,格外需要主動驗證。

一份最小可行的 OTel Collector sampling 設定示意

以下是教學用的設定形狀,示範「錯誤全留、成功低比例抽樣」在 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 順序把「值得全留」和「可以抽樣」分開處理。

⑭ 反例:三個看似合理、實際會害人的設計

反例一:把整份 request object 寫入 error log

好處很誘惑:事故一發生,所有輸入都在。代價是 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 成功率取代 SLI

若 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 用綠/黃/紅快速標示狀態,方便一眼掃過整排面板,但顏色背後的門檻值往往是很久以前設定的,隨流量成長早就不合時宜——面板綠色,不代表數字真的健康,只代表還沒跨過那條可能過時的線。更隱蔽的是 dashboard 顯示的是預先算好的聚合值而非即時查詢,事故當下可能把「面板剛好沒更新」誤判成「系統真的沒事」。

養成習慣:事故排查時至少對一個關鍵面板點進去看原始查詢語句,確認「這個數字代表什麼」和查詢實際在算的東西一致——不會花超過一分鐘,卻能避免把「儀表板沒有紅」誤讀成「服務沒有問題」。

auto-instrumentation vs 手動 span:不是二選一

本文 ⑨ 段示範的都是手動開 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,該手動標記的才手動寫。

⑮ 上線前的最小 runbook

你不需要先寫一本事故百科全書。先讓 /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 決定。

為什麼第 7 步刻意寫「記錄未知處,不假裝已找到根因」

值班工程師常有一種心理壓力:覺得「必須交出一個根因」才算完成任務,即使證據只夠支持到「可能是這個原因」,也會被誘使直接寫成定論,之後很難再有人回頭挑戰它。

Google SRE Book 的 postmortem 文化強調「blameless」與「誠實記錄不確定性」,原因正是這個機制——寫死「根因是 X」卻證據不足,比誠實寫「目前證據指向 X,但 Y 尚未排除」更危險,前者會讓團隊誤以為問題已解決,直到同一類故障下次用不同樣貌重演。三種訊號的證據終究有限,runbook 該鼓勵誠實標注邊界,而不是逼出一個看似完整、實際拼湊出來的故事。

runbook 是活的文件,不是寫完就封存

這份 runbook 每一步都對應本文前段的具體規則,本來就該是把①到⑭學到的東西收斂成值班時真正會翻開的那一頁。也因此該隨服務演化持續更新:新增一個 dependency,第 3 步的篩選欄位要跟著擴充;換了 trace backend,第 4 步的查詢方式也要同步修改。當成一次性交付物寫完就不管,它會脫節成看似完整、實際誤導新人的過時文件——和 ⑭ 段反例四「只信任過時的 dashboard 門檻」是同一種風險,只是換了一種載體。

⑯ Production Takeaway

如果這是要上線的 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 做得漂亮更重要。

下一篇:Day 22|Golden Signals、RED、USE

下一篇會接著處理「同樣一套 metrics、logs、traces,dashboard 的第一眼該放哪些指標」。

延伸閱讀


這篇是 Learning SRE for the AI Era 系列的一部分。

我會從 SRE 的服務可靠性基礎開始,逐步探索當系統加入 LLM、RAG、Agent 與 GPU Infrastructure 後,如何讓 AI 系統不只可用,也能被觀測、評估、控制成本並安全演進。

Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 21(上)|Metrics、Logs、Traces:同一件事,三種問題
下一篇
Day 22(上)|Golden Signals、RED、USE:框架是起點,不是儀表板模板
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言