快轉到主要內容
  1. 教學文章/

Python OpenTelemetry 實戰:Trace、Span 與 FastAPI 觀測流程

·6 分鐘· loading · loading · ·
Python OpenTelemetry Observability FastAPI Tracing Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 94: 本文

featured

程式在本機跑得好好的,上線之後卻開始變成一團霧。 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 loggingPython 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 最實際的價值: 不是看起來很專業,而是半夜出事時,你不用靠通靈。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 94: 本文

相關文章

FastAPI + Streamlit 實戰:API 後端與互動前端分工
·9 分鐘· loading · loading
Python FastAPI Streamlit Api Frontend Developer-Tools
Python uv build/publish 實戰:從 wheel 到 private package workflow
·11 分鐘· loading · loading
Python Uv Packaging Wheel PyPI Private-Package Developer-Tools
Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台
·9 分鐘· loading · loading
Python Streamlit SQLModel SQLite CRUD Developer-Tools
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools
Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools
Python inspect 實戰:看懂函式簽名、物件結構與開發工具自動化
·6 分鐘· loading · loading
Python Inspect Introspection Standard-Library Developer-Tools