线上接口返回 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[standard]" 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[str] = 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[key] = 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 连接技术指标与服务承诺,并在多服务环境中统一约定错误码、关联头和日志字段。这样指标发现错误率升高,追踪定位慢在库存服务,日志再回答库存服务拒绝了哪个业务条件,形成完整闭环。 |