程式在本機跑得好好的,上線之後卻開始變成一團霧。 API 偶爾變慢,worker 偶爾失敗,第三方服務偶爾 timeout。 每個服務都有 log,但 log 分散在不同地方。 你知道「某個 request 壞了」,卻不知道它一路經過哪些函式、打了哪些 API、卡在哪一段。
這時候 OpenTelemetry 就很有用了。 它不是拿來取代 log,也不是只給大型微服務用。 它比較像幫每個 request 發一張旅行票: 它從進門開始被追蹤,經過哪個 handler、哪個 database query、哪個 HTTP request,都能被串起來。
今天拍拍君要用 Python 做一個小型 FastAPI 範例。 我們會從最小 trace 開始,慢慢加上 FastAPI instrumentation、HTTPX client、Jaeger 檢視、手動 span 與 log correlation。 看完之後,你不需要馬上把整個系統變成 observability 平台。 但你會知道 trace 要怎麼放進日常開發工具箱。
如果你只是想整理 log,可以先看 Python logging 或 Python loguru。 如果你想做 CLI 內的狀態面板,可以看 Rich logging dashboard。 這篇處理的是另一件事:一次 request 的完整路徑。
一. 先搞懂三個詞 #
OpenTelemetry 文件裡會出現很多名詞。 先不要被嚇到。 這篇先抓三個最重要的:
- Trace:一次請求或工作流程的完整旅程
- Span:旅程中的一小段工作
- Context:讓 trace id 能跨函式、跨執行緒、跨 HTTP request 傳下去的上下文
可以想像一個使用者按下「送出訂單」:
trace: checkout request
span: validate cart
span: reserve inventory
span: charge payment
span: send receipt
整個 checkout 是一個 trace。
裡面的每一步是 span。
如果 charge payment 慢到 3 秒,你可以直接在 trace viewer 裡看到。
兩者不是敵人。 好的系統通常需要 log、metric、trace 一起看。
二. 建立範例專案 #
先建立一個最小專案。
這篇用 uv,不用也可以改成一般 venv。
uv init otel-fastapi-demo
cd otel-fastapi-demo
uv add fastapi uvicorn httpx
uv add opentelemetry-api opentelemetry-sdk
uv add opentelemetry-exporter-otlp
uv add opentelemetry-instrumentation-fastapi
uv add opentelemetry-instrumentation-httpx
我們先不急著接雲端服務。 本機用 Jaeger 看 trace 就好。
建立 docker-compose.yaml:
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686"
- "4317:4317"
- "4318:4318"
environment:
COLLECTOR_OTLP_ENABLED: "true"
啟動:
docker compose up -d
瀏覽器開 http://localhost:16686,現在還沒有任何 trace,等一下就會有。
三. 第一個手動 Trace #
先從完全不牽涉 web framework 的版本開始。 核心設定大概長這樣:
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
resource = Resource.create({"service.name": "otel-demo-client"})
provider = TracerProvider(resource=resource)
processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
接著在工作流程裡開 span:
def checkout(payload: dict) -> None:
with tracer.start_as_current_span("checkout") as span:
span.set_attribute("checkout.user_id", payload["user_id"])
validate_payload(payload)
reserve_inventory(payload)
def validate_payload(payload: dict) -> None:
with tracer.start_as_current_span("validate_payload") as span:
span.set_attribute("payload.items", len(payload["items"]))
span.set_attribute("validation.ok", bool(payload["items"]))
def reserve_inventory(payload: dict) -> None:
with tracer.start_as_current_span("reserve_inventory") as span:
for item in payload["items"]:
span.add_event("reserve item", {"sku": item["sku"]})
執行:
uv run python client.py
到 Jaeger UI 搜尋 otel-demo-client。
你應該會看到一條 checkout trace。
service.name 很重要。
沒有它,trace viewer 裡會很難分辨是哪個服務送出的資料。
start_as_current_span() 則會把巢狀函式串成同一條 trace。
四. Attribute 與 Event 怎麼放? #
Span 裡最常放兩種資訊:attribute 與 event。
Attribute 適合描述這段 span 的穩定欄位:
span.set_attribute("checkout.user_id", payload["user_id"])
span.set_attribute("payload.items", len(payload["items"]))
span.set_attribute("validation.ok", True)
Event 適合描述這段 span 中發生的時間點:
span.add_event("reserve item", {"sku": item["sku"]})
span.add_event("payment retry", {"attempt": 2})
拍拍君的粗略規則是:
- 之後想拿來篩選或分組的,放 attribute
- 只是想知道過程中發生過什麼,放 event
- 不要放密碼、token、完整個資
- 不要把超大 payload 整包塞進 span
Trace 是除錯線索,不是資料湖。 把整包 request body 都塞進去,短期看起來很方便,長期會很痛。
五. 接上 FastAPI #
接著建立 app.py。
我們要讓每個 HTTP request 自動產生 trace。
import asyncio
import httpx
from fastapi import FastAPI, HTTPException
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
setup_tracing() # 就是上一節的 provider/exporter 設定,service.name 換成 checkout-api
HTTPXClientInstrumentor().instrument()
tracer = trace.get_tracer(__name__)
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
async def reserve_inventory(sku: str, qty: int) -> None:
with tracer.start_as_current_span("reserve_inventory") as span:
span.set_attribute("inventory.sku", sku)
span.set_attribute("inventory.qty", qty)
await asyncio.sleep(0.05)
async def call_payment_gateway(amount: int) -> dict:
with tracer.start_as_current_span("call_payment_gateway") as span:
span.set_attribute("payment.amount", amount)
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get("https://httpbin.org/status/200")
span.set_attribute("payment.http_status", response.status_code)
return {"status": "ok", "code": response.status_code}
@app.post("/checkout")
async def checkout(payload: dict) -> dict:
items = payload.get("items", [])
if not items:
raise HTTPException(status_code=400, detail="empty cart")
with tracer.start_as_current_span("checkout_business_logic") as span:
span.set_attribute("checkout.items", len(items))
for item in items:
await reserve_inventory(item["sku"], item["qty"])
amount = sum(item["qty"] * 100 for item in items)
payment = await call_payment_gateway(amount)
return {"ok": True, "payment": payment, "amount": amount}
啟動 API:
uv run uvicorn app:app --reload
送一個 request:
curl -X POST http://localhost:8000/checkout \
-H 'content-type: application/json' \
-d '{
"items": [
{"sku": "coffee-beans", "qty": 1},
{"sku": "notebook", "qty": 2}
]
}'
到 Jaeger 搜尋 checkout-api。
你會看到 request span、business logic span、inventory span,以及 HTTPX 對外呼叫的 span。
這就是 instrumentation 的好處。 你不需要在每個 framework 入口都手寫 span。 FastAPI 與 HTTPX 的常見資訊會自動被收集。
六. 手動 Span 放在哪裡最划算? #
自動 instrumentation 很方便,但它只知道 framework 層級。
它知道你打了 /checkout。
它知道你呼叫了 httpbin.org。
它不知道你的商業邏輯正在做什麼。
所以手動 span 最適合放在這些地方:
- 重要的 domain step
- 可能變慢的外部資源
- 需要拆解責任邊界的函式
- 失敗時很難從 log 反推的流程
例如:
with tracer.start_as_current_span("apply_coupon") as span:
span.set_attribute("coupon.code", coupon_code)
discount = calculate_discount(cart, coupon_code)
span.set_attribute("coupon.discount", discount)
但不要把每一行都包成 span。 Trace 太細會變成雜訊。 比較好的粒度是「未來你真的會問:這一段花了多久?」。
七. Trace Context、Log 與錯誤 #
OpenTelemetry 的價值不只在單一服務。 當 FastAPI 收到 request 時,它會讀取 HTTP header 裡的 trace context。 當 HTTPX 發出 request 時,它也會把目前的 trace context 注入 header。
常見 header 長這樣:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
你平常不需要手動處理它。 只要上下游都有 instrumentation,trace 就能一路串下去。
Trace 很會回答「路徑」,log 很會回答「細節」。
所以正式一點的服務通常會把 trace_id 放進 log:
import logging
from opentelemetry import trace
logger = logging.getLogger("checkout")
def log_step(message: str) -> None:
context = trace.get_current_span().get_span_context()
trace_id = format(context.trace_id, "032x") if context.is_valid else "-"
logger.info("%s trace_id=%s", message, trace_id)
這樣你在 log 裡看到錯誤時,可以拿 trace id 去 Jaeger 搜尋。 反過來在 trace viewer 裡看到慢 span,也能回 log 找當時細節。
錯誤也可以用 span.record_exception(exc) 記在 span 裡,並加上 error.type 這類 attribute。
拍拍君會避免在 span 裡塞完整個資、token、超大 response body。
Trace 是除錯線索,不是資料湖。
八. Sampling 與導入順序 #
本機開發可以每個 request 都收。 正式環境通常要 sampling,否則儲存成本會膨脹。
例如 TraceIdRatioBased(0.1) 就是只收 10%。
拍拍君建議的導入順序很單純:
1. 先把 Jaeger 用 docker compose 跑起來
2. 在單一 FastAPI app 加上 instrumentation
3. 在最重要的 handler 加 2-4 個手動 span
4. 在 HTTPX 或資料庫 client 加 instrumentation
5. 把 trace_id 放進 log
6. 打開 Jaeger 看 span 粒度是否真的有幫助
常見踩雷也很好記:
- 忘記設定
service.name - 每次 request 都重新建立 provider
- 把 PII 放進 attribute
- 只裝 instrumentation,卻沒有 domain span
- 把 tracing 當成 logging
好的 trace 應該像一份清楚的流程圖。 不是流水帳,也不是黑箱。
結語 #
OpenTelemetry 最迷人的地方,是它讓「感覺很慢」變成「哪一段很慢」。 也讓「某個 API 壞掉」變成「這次 request 經過哪些步驟,最後在哪裡壞掉」。
對 Python 開發者來說,最好的起點不是把所有東西一次接完。 而是挑一條你常常 debug 的路徑,先加上 FastAPI/HTTPX instrumentation,再補幾個有意義的手動 span。
當 trace、log、error 可以互相對上時,除錯會少很多猜測。 拍拍君覺得這就是 observability 最實際的價值: 不是看起來很專業,而是半夜出事時,你不用靠通靈。