TrackingID的设计
前言
在实际业务中,根据 tracking_id 追查日志中一条请求的完整处理路径是一个比较常见的需求。不过 FastAPI 官方并没有提供相对应的功能,因此需要开发者自行实现。本文介绍如何基于 contextvars,为每次请求的完整流程都添加一个 tracking_id,并在日志中自动记录。
什么是 contextvars
Python 在 3.7 版本的标准库中加入了一个模块 contextvars,顾名思义就是 "(Context Variables) 上下文变量",通常用来隐式地传递一些环境信息的变量,其作用跟 threading.local() 比较相似。不过 threading.local() 是针对线程的,隔离线程之间的数据状态,而 contextvars 可以用在 asyncio 生态的异步协程中。
提示
contextvars 不仅可以用在异步协程中,也可以替代 threading.local() 用在多线程函数中。
基本使用
- 首先编写
context.py
import contextvars
from typing import Optional
TRACKING_ID: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
'tracking_id',
default=None
)
def get_tracking_id() -> Optional[str]:
"""用于依赖注入"""
return TRACKING_ID.get()
- 编写中间件
middlewares.py,在请求头和响应头中添加 tracking_id 的信息。常见场景就是客户拿着 tracking_id 找碴。
import uuid
from starlette.middleware.base import (BaseHTTPMiddleware,
RequestResponseEndpoint)
from starlette.requests import Request
from starlette.responses import Response
from context import TRACKING_ID
class TrackingIDMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
tracking_id = str(uuid.uuid4())
token = TRACKING_ID.set(tracking_id)
# HTTP 请求头习惯于使用 latin-1 编码
request.scope["headers"].append((b"x-request-id", tracking_id.encode("latin-1")))
try:
resp = await call_next(request)
finally:
# 无论是否成功,每次请求结束时重置 tracking_id,避免泄露到下一次的请求中
TRACKING_ID.reset(token)
# 可选, 在响应中设置跟踪 ID 头
resp.headers["X-Tracking-ID"] = tracking_id
return resp
- 编写 handler 函数
handlers.py,测试在 handler 函数中获取 tracking_id。
import asyncio
from context import TRACKING_ID
async def mock_db_query():
await asyncio.sleep(1)
current_id = TRACKING_ID.get()
print(f"This is mock_db_query. Current tracking ID: {current_id}")
await asyncio.sleep(1)
- 编写主函数
main.py
import uvicorn
from fastapi import Depends, FastAPI
from fastapi.responses import PlainTextResponse
from starlette.background import BackgroundTasks
from context import TRACKING_ID, get_tracking_id
from handlers import mock_db_query
from middlewares import TrackingIDMiddleware
app = FastAPI()
app.add_middleware(TrackingIDMiddleware)
@app.get("/qwer")
async def get_qwer():
"""测试上下文变量传递"""
current_id = TRACKING_ID.get()
print(f"This is get qwer. Current tracking ID: {current_id}")
return PlainTextResponse(f"Current tracking ID: {current_id}")
@app.get("/asdf")
async def get_asdf(tracking_id: str = Depends(get_tracking_id)):
"""测试依赖注入"""
print(f"This is get asdf. tracking ID: {tracking_id}")
await mock_db_query()
return PlainTextResponse(f"Get request, tracking ID: {tracking_id}")
if __name__ == "__main__":
uvicorn.run("main:app", host="127.0.0.1", port=8000, workers=4)
- 启动服务后用 curl 测试 api,在控制台可以看到 tracking_id 在请求中都能捕获到。
This is get qwer. Current tracking ID: 01b0153f-4877-4ca0-ac35-ed88ab406452
INFO: 127.0.0.1:55708 - "GET /qwer HTTP/1.1" 200 OK
This is get asdf. tracking ID: 0be61d8d-11a0-4cb6-812f-51b9bfdc2639
This is mock_db_query. Current tracking ID: 0be61d8d-11a0-4cb6-812f-51b9bfdc2639
INFO: 127.0.0.1:55722 - "GET /asdf HTTP/1.1" 200 OK
使用 curl 的控制台输出
$ curl -i http://127.0.0.1:8000/qwer
HTTP/1.1 200 OK
date: Sat, 29 Nov 2025 06:16:46 GMT
server: uvicorn
content-length: 57
content-type: text/plain; charset=utf-8
x-tracking-id: 01b0153f-4877-4ca0-ac35-ed88ab406452
Current tracking ID: 01b0153f-4877-4ca0-ac35-ed88ab406452
===== 另一个请求 =====
$ curl -i http://127.0.0.1:8000/asdf
HTTP/1.1 200 OK
date: Sat, 29 Nov 2025 06:16:49 GMT
server: uvicorn
content-length: 62
content-type: text/plain; charset=utf-8
x-tracking-id: 0be61d8d-11a0-4cb6-812f-51b9bfdc2639
Get request, tracking ID: 0be61d8d-11a0-4cb6-812f-51b9bfdc2639
后台任务型 API
FastAPI 中有 from starlette.background import BackgroundTasks 可以让接口直接响应,将实际流程放到后台异步处理。因为上面的中间件在响应时会重置 tracking_id,所以后台的协程函数可能不会获取到 tracking_id。理论上是这样的,但是本地测试时发现在异步协程中还是能获取到 tracking_id,这可能是本地低并发的问题。在生产环境高并发的情况下,最好还是强制解耦,显式传递 contextvars。
- 自定义的中间件类保持不变。
- 添加后台任务的 api
from starlette.background import BackgroundTasks
from handlers import mock_backgroud_task
@app.get("/zxcv")
async def get_zxcv(tasks: BackgroundTasks):
"""测试后台任务"""
current_id = TRACKING_ID.get()
print(f"This is get zxcv. The current id is {current_id}")
# 显式传递 tracking_id
tasks.add_task(mock_backgroud_task, current_id)
return PlainTextResponse(f"This is get zxcv. The current id is {current_id}")
- 后台协程函数中显式处理。任务在启动时,使用传入的参数值,在自己的任务执行上下文中,重新设置
TRACKING_ID。在任务结束时,对任务自己设置的上下文进行清理。
async def mock_backgroud_task(request_tracking_id: Optional[str]):
if request_tracking_id is None:
request_tracking_id = str(uuid4())
print(f"WARNING: No tracking ID found. Generate a new one: {request_tracking_id}")
token = TRACKING_ID.set(request_tracking_id)
try:
# 模拟耗时的后台异步任务
await asyncio.sleep(5)
print(f"This is mock backgroud task. Current tracking ID: {request_tracking_id}")
finally:
# 确保 tracking ID 被重置
TRACKING_ID.reset(token)
- 启动主程序并测试 tracking_id 是否一致。