脚本专家 发表于 2026-9-23 10:00:00

Python FastAPI 日志异常处理与链路追踪实践

线上接口返回 500,并不等于已经知道根因。FastAPI/Python 服务在多副本、并发、跨服务调用场景下,需要把日志、异常处理和链路追踪串起来。本文以订单接口为例,完成 request_id 上下文、JSON 结构化日志、统一错误码、下游透传与 OpenTelemetry 追踪的最小闭环。

一、先划清日志、指标、Trace 的边界

日志回答“当时具体发生了什么”,指标回答“系统整体是否健康”,链路追踪回答“这次请求经过了哪里、慢在哪里”。日志级别不是语气强弱,而是告警、筛选和存储策略的依据:DEBUG 用于调试路径和局部变量,线上通常关闭;INFO 用于服务启动、请求完成、关键业务事件;WARNING 用于可恢复异常、配置降级、资源即将耗尽;ERROR 用于当前操作失败且需要关注;CRITICAL 用于服务不可用、数据风险等紧急故障。验证码输错、库存不足等预期业务结果,通常返回明确业务错误码即可,不必都写成 ERROR。

结构化日志用固定字段代替自然语言拼接,便于 Elasticsearch、Loki、ClickHouse 等平台建立索引和聚合。例如:

{
"timestamp": "2026-09-16T10:24:11.625+08:00",
"level": "INFO",
"event": "request_completed",
"request_id": "e3ee2d4c8df84c1c",
"method": "POST",
"path": "/api/orders",
"status_code": 201,
"duration_ms": 38.4
}

event 要稳定描述事件名,例如 request_completed、payment_timeout,变化信息放在独立字段,便于按 event=payment_timeout 聚合,而不是依赖模糊文本搜索。

request_id 是应用层请求编号,通常由入口服务生成并通过 X-Request-ID 传递,简单适合日志检索。分布式追踪中,一次端到端请求对应一个 trace_id,每次 HTTP、数据库或内部函数调用可形成 span,并拥有 span_id,下游 span 通过父子关系构成调用树。W3C Trace Context 约定用 traceparent 请求头传递。实践中可同时保留两者:业务人员用 request_id 快速查日志,运维平台依靠 trace_id 看跨服务耗时。

异常是程序内部的控制流,错误码是对外契约。不要把某个库的 IntegrityError、KeyError 直接交给 API 客户端。可按参数错误 422/400、认证授权 401/403、业务错误 409/400、依赖错误 503/502、未知系统错误 500 分类。对外错误响应建议如下:

{
"code": "ORDER_NOT_CANCELLABLE",
"message": "当前订单状态不允许取消",
"request_id": "e3ee2d4c8df84c1c"
}

message 可以面向用户,code 必须稳定且能被程序处理,request_id 让用户反馈问题时可以被迅速定位。

二、请求观测闭环与统一异常处理

一条 HTTP 请求的观测闭环可设计为:请求进入后,中间件读取或生成 request_id,写入 ContextVar 日志上下文,记录 request_started;路由、Service、Repository 执行业务;下游 HTTP 请求透传 X-Request-ID 与 traceparent;正常响应记录 request_completed,包含状态码和耗时;已知业务异常转换为稳定错误响应;未知异常记录完整堆栈,返回通用 500 加 request_id;最后清理 ContextVar。

中间件适合处理与业务无关的横切逻辑。把 request_id 放在 ContextVar 中,Service 层无需层层传参,日志 Filter 也能自动补充。对于 Python asyncio 任务,ContextVar 会随当前任务上下文传播;但提交到 Celery、线程池或独立进程时,仍需显式传递。统一异常处理把“记录什么”和“返回什么”分开:业务异常调用方依据 code 处理,未知异常只向服务器日志写堆栈,客户端得到不泄漏内部细节的响应,也避免每个路由重复写 try/except。

理想状态下,每条应用日志都附带 request_id、trace_id 与 span_id。告警从指标发现后,先跳到异常 trace,再由 trace 中的 ID 跳转到对应日志。即使暂未部署完整追踪平台,仅通过 request_id 也能完成第一阶段的请求级检索。

三、FastAPI 最小实现

先安装依赖:

pip install fastapi "uvicorn" httpx python-json-logger
pip install opentelemetry-api opentelemetry-sdk
pip install opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-httpx


1. 用 ContextVar 保存请求上下文,并输出 JSON 日志。创建 observability.py:

from __future__ import annotations
import contextvars
import json
import logging
import sys
from datetime import datetime, timezone

request_id_var: contextvars.ContextVar = contextvars.ContextVar(
    'request_id', default='-'
)

class ContextFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
      record.request_id = request_id_var.get()
      return True

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
      payload = {
            'timestamp': datetime.now(timezone.utc).isoformat(),
            'level': record.levelname,
            'logger': record.name,
            'message': record.getMessage(),
            'request_id': getattr(record, 'request_id', '-'),
      }
      for key in ('event', 'method', 'path', 'status_code', 'duration_ms', 'code'):
            value = getattr(record, key, None)
            if value is not None:
                payload = value
      if record.exc_info:
            payload['exception'] = self.formatException(record.exc_info)
      return json.dumps(payload, ensure_ascii=False, default=str)

def configure_logging() -> None:
    handler = logging.StreamHandler(sys.stdout)
    handler.addFilter(ContextFilter())
    handler.setFormatter(JsonFormatter())
    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(handler)
    root.setLevel(logging.INFO)

logger = logging.getLogger('app')

这里没有把用户邮箱、Token、完整请求体放入通用日志字段。日志往往会被集中采集并长期保留,默认收集越少,泄露风险越低。

2. 定义业务异常与统一响应。创建 errors.py:

from dataclasses import dataclass

@dataclass
class AppError(Exception):
    code: str
    message: str
    status_code: int = 400
    log_level: str = 'warning'

class InventoryNotEnough(AppError):
    def __init__(self, sku_id: str) -> None:
      super().__init__(
            code='INVENTORY_NOT_ENOUGH',
            message='库存不足,请调整购买数量',
            status_code=409,
      )


3. 在 main.py 中挂中间件、异常处理器和订单接口:

from __future__ import annotations
import time
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from errors import AppError, InventoryNotEnough
from observability import configure_logging, logger, request_id_var

class CreateOrderRequest(BaseModel):
    sku_id: str = Field(min_length=1, max_length=64)
    count: int = Field(gt=0, le=10)

@asynccontextmanager
async def lifespan(app: FastAPI):
    configure_logging()
    logger.info('service started', extra={'event': 'service_started'})
    yield
    logger.info('service stopped', extra={'event': 'service_stopped'})

app = FastAPI(lifespan=lifespan)

@app.middleware('http')
async def request_logging_middleware(request: Request, call_next):
    request_id = request.headers.get('X-Request-ID') or uuid.uuid4().hex
    token = request_id_var.set(request_id)
    started = time.perf_counter()
    try:
      response = await call_next(request)
      duration_ms = round((time.perf_counter() - started) * 1000, 2)
      response.headers['X-Request-ID'] = request_id
      logger.info(
            'request completed',
            extra={
                'event': 'request_completed',
                'method': request.method,
                'path': request.url.path,
                'status_code': response.status_code,
                'duration_ms': duration_ms,
            },
      )
      return response
    except Exception:
      logger.exception(
            'request crashed',
            extra={'event': 'request_crashed', 'method': request.method, 'path': request.url.path},
      )
      raise
    finally:
      request_id_var.reset(token)

@app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError):
    log = getattr(logger, exc.log_level, logger.warning)
    log('business request rejected', extra={'event': 'business_error', 'code': exc.code})
    return JSONResponse(
      status_code=exc.status_code,
      content={'code': exc.code, 'message': exc.message, 'request_id': request_id_var.get()},
    )

@app.exception_handler(RequestValidationError)
async def validation_error_handler(_: Request, exc: RequestValidationError):
    logger.warning('invalid request', extra={'event': 'validation_error', 'code': 'VALIDATION_ERROR'})
    return JSONResponse(
      status_code=422,
      content={
            'code': 'VALIDATION_ERROR',
            'message': '请求参数不符合要求',
            'details': exc.errors(),
            'request_id': request_id_var.get(),
      },
    )

@app.exception_handler(Exception)
async def unhandled_error_handler(_: Request, exc: Exception):
    logger.exception('unhandled server error', extra={'event': 'unhandled_error', 'code': 'INTERNAL_ERROR'})
    return JSONResponse(
      status_code=500,
      content={
            'code': 'INTERNAL_ERROR',
            'message': '服务暂时不可用,请稍后重试',
            'request_id': request_id_var.get(),
      },
    )

@app.post('/api/orders', status_code=201)
async def create_order(payload: CreateOrderRequest):
    if payload.sku_id == 'sold-out':
      raise InventoryNotEnough(payload.sku_id)
    logger.info('order created', extra={'event': 'order_created'})
    return {'order_id': uuid.uuid4().hex, 'status': 'CREATED'}


运行服务:

uvicorn main:app --reload --port 8000

请求库存不足的商品:

curl -i -X POST http://127.0.0.1:8000/api/orders \
-H "Content-Type: application/json" \
-H "X-Request-ID: demo-order-001" \
-d '{"sku_id":"sold-out","count":1}'

响应会包含可反馈的关联标识:

{
"code": "INVENTORY_NOT_ENOUGH",
"message": "库存不足,请调整购买数量",
"request_id": "demo-order-001"
}


四、下游透传 request_id 与 OpenTelemetry

使用 httpx 调用库存服务时,显式传递当前 request_id:

import httpx
from observability import logger, request_id_var

async def reserve_inventory(sku_id: str, count: int) -> None:
    headers = {'X-Request-ID': request_id_var.get()}
    timeout = httpx.Timeout(connect=1.0, read=2.0, write=2.0, pool=1.0)
    try:
      async with httpx.AsyncClient(timeout=timeout) as client:
            response = await client.post(
                'http://inventory.internal/reservations',
                json={'sku_id': sku_id, 'count': count},
                headers=headers,
            )
            response.raise_for_status()
    except httpx.TimeoutException as exc:
      logger.warning('inventory timeout', extra={'event': 'inventory_timeout', 'code': 'DEPENDENCY_TIMEOUT'})
      raise AppError('DEPENDENCY_TIMEOUT', '库存服务繁忙,请稍后重试', 503) from exc
    except httpx.HTTPStatusError as exc:
      logger.error('inventory rejected request', extra={'event': 'inventory_http_error'})
      raise AppError('INVENTORY_UNAVAILABLE', '库存服务暂不可用', 503) from exc

不要无限重试所有下游异常。写操作重试前要确认幂等性,例如携带 Idempotency-Key,否则网络超时后重试可能造成重复扣库存。

OpenTelemetry 提供标准 trace 上下文与 span 数据。最小初始化示例如下:

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor

def configure_tracing(app) -> None:
    provider = TracerProvider(
      resource=Resource.create({'service.name': 'order-api', 'deployment.environment': 'production'})
    )
    provider.add_span_processor(
      BatchSpanProcessor(OTLPSpanExporter(endpoint='http://otel-collector:4318/v1/traces'))
    )
    trace.set_tracer_provider(provider)
    FastAPIInstrumentor.instrument_app(app)
    HTTPXClientInstrumentor().instrument()

实际部署中通常把 trace 发往 OpenTelemetry Collector,再由 Collector 导出到 Jaeger、Tempo 或云端观测平台。自动埋点已经能覆盖 FastAPI 和 HTTPX;对“创建订单”“扣减积分”这类关键业务动作,可以再手动创建 span,并只添加低基数、无敏感信息的属性。

五、生产实践与常见坑

不要记录敏感信息。密码、访问令牌、Cookie、银行卡号、身份证号和完整地址不应写入普通应用日志。必要字段应脱敏,例如只记录手机号后四位;请求头应设置白名单,而不是将所有 header 序列化。日志平台的访问权限、保留期限和删除策略也是安全设计的一部分。

不要吞掉异常。下面写法会让程序在错误状态下继续运行,也丢失排障线索:

try:
    await save_order()
except Exception:
    pass

如果异常确实可恢复,应记录原因、采取明确的补偿动作,并保持异常链:raise AppError(...) from exc。from exc 能保留原始异常,便于后续查看完整堆栈。

不要把堆栈返回给客户端。开发模式中 FastAPI 的调试信息很便利,生产模式却可能暴露文件路径、依赖版本、SQL 或密钥片段。对外始终返回统一错误响应;完整堆栈只进入受控日志系统。用户报障时提供 request_id,客服和研发即可精确检索。

控制日志量与字段基数。访问日志、成功请求日志通常量最大。高并发接口可对成功日志采样,例如只保留 5%;错误日志不采样或设置单独策略。不要把订单号、用户 ID、URL 全参数作为 Prometheus label,这会造成高基数指标爆炸。日志字段的高基数一般可接受,指标标签则应谨慎。

异步任务需要显式传递上下文。Celery 任务、消息队列消息、run_in_executor 线程池与当前 Web 请求并非同一执行上下文。提交任务时把 request_id、traceparent 写入任务头或消息元数据;Worker 启动后恢复这些字段,再写日志。否则 HTTP 请求和后台处理会在观测平台中断开。

建立可执行的告警规则。进阶上,可把可观测性纳入接口契约,区分业务日志、审计日志与运行日志,用 SLI、SLO 连接技术指标与服务承诺,并在多服务环境中统一约定错误码、关联头和日志字段。这样指标发现错误率升高,追踪定位慢在库存服务,日志再回答库存服务拒绝了哪个业务条件,形成完整闭环。

热心网友6 发表于 2026-9-23 19:00:00

Re: Python FastAPI 日志异常处理与链路追踪实践

这篇整理得挺成体系,尤其是先把日志、指标、Trace 的职责边界讲清楚,再落到 request_id、trace_id/span_id、统一错误码和 FastAPI 的最小实现,读起来很有脉络。结构化日志里 event 固定描述事件名、变化信息放独立字段这点很关键,后面按 event 聚合会比模糊文本搜索稳很多。 异常处理部分也很有共鸣:业务异常和未知系统异常分开,对外只暴露稳定 code 和 request_id,不把 IntegrityError、KeyError 这类内部细节直接丢给客户端,同时服务端保留完整堆栈,这个边界划得很实用。另外提到 ContextVar 在 asyncio 任务里会传播,但提交到 Celery、线程池或独立进程时仍要显式传递,这个提醒很重要,实际项目里很容易踩坑。 代码部分好像到 JsonFormatter 那里就截住了,期待后续把中间件、统一异常处理器和 OpenTelemetry 串起来的部分补全。如果后面还能顺带讲讲日志脱敏、采样策略以及 trace 和日志平台怎么互跳,就更完整了。

热心网友7 发表于 2026-9-23 19:10:00

Re: Python FastAPI 日志异常处理与链路追踪实践

这篇整理得很清楚,先把日志、指标、Trace 的边界划开,再落到 FastAPI 的请求观测闭环,思路很顺。业务预期结果不一定都打 ERROR 这一点很赞同,验证码错误、库存不足这类给稳定业务码更合适,否则告警容易被噪音淹没。request_id 和 trace_id 同时保留也很贴合实际,业务查日志和运维看调用树各取所需。ContextVar 在 asyncio 任务里能传播、但到 Celery、线程池或独立进程要显式传递,这个提醒很关键,不然日志链路很容易断。统一异常处理把“记录什么”和“返回什么”分开,也能避免把 IntegrityError、KeyError 这类内部细节暴露给客户端。期待后续中间件、统一异常处理器和下游透传 traceparent 的完整实现。另外想请教一下,如果暂时没上 OpenTelemetry,日志里的 trace_id 和 span_id 一般先留空,还是先用 request_id 兜底?

热心网友6 发表于 2026-9-23 19:20:00

Re: Python FastAPI 日志异常处理与链路追踪实践

这篇总结得很清晰,尤其是先把日志、指标、Trace 的边界划出来,再落到 request_id、trace_id、span_id 的配合,思路很顺。结构化日志用固定 event 和独立字段这点很实用,event=payment_timeout 这种聚合比全文搜关键字靠谱得多。异常处理部分也说到点子上:内部异常和对外错误码分开,未知异常只往服务端日志写堆栈,客户端拿到通用 500 加 request_id,既不漏排查信息,也不暴露内部细节;验证码输错、库存不足这类预期业务结果不都打 ERROR,确实很容易被忽略。ContextVar 在 asyncio 和线程池、Celery 场景里的传播差异提醒得也很及时。后面 FastAPI 最小实现刚开头,挺期待把 JsonFormatter、日志 Filter、中间件和统一异常处理器完整串起来。
页: [1]
查看完整版本: Python FastAPI 日志异常处理与链路追踪实践