feat(observability): 失败响应与网关信封进链路,手工埋点尊重 suppressed
失败此前在 trace 里近乎不可见:异常处理器把异常吃掉换成信封响应,自动 instrumentation 只看到一个 HTTP 状态码,而 AppError 默认 400、信封里 success=false,跟正常返回分不出来。 - api.py:四个异常处理器(对外失败的唯一出口)各记一次 span;兜底处理器额外 record_error——对外只回一句无信息量的错误文案,异常类型与栈只在本地日志里 - telemetry.py:新增 record_envelope / record_parse_failure / span_unless_suppressed;record_error 补 error.message / retryable / status_code;snapshot 支持 extra 带上「这份 HTML 是哪来的」 - worker/client.py:_request 自建 span,活到解信封之后。httpx 那个 CLIENT span 在 request() 返回时就结束,此时信封还没解——success=false code=6002(租约 无效)在它看来是完成的 200 请求 - span_unless_suppressed:suppress_instrumentation 只被 instrumentation 库尊重, 手工 span 不看它,lease 空转长轮询会从这个口子把孤立 trace 放回来 - 两个 scraping client 的解析失败分支收拢到 record_parse_failure;shop_items 显式标 stage=delegate 且不落快照(它自己不抓页面,按 html 推断只会得出 「fetch 失败」的错误结论) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+44
-3
@@ -16,10 +16,12 @@ from typing import Any, Generic, TypeVar
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from opentelemetry import trace
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.shared.errors import AppError, AuthenticationError
|
||||
from app.shared.telemetry import record_envelope, record_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -98,16 +100,43 @@ def jsonable_errors(errors: list[dict]) -> list[dict]:
|
||||
return [{key: value for key, value in error.items() if key != "ctx"} for error in errors]
|
||||
|
||||
|
||||
def _trace_failure(
|
||||
exc: BaseException | None, *, err_code: int, msg: str, status_code: int
|
||||
) -> None:
|
||||
"""把一次失败响应记到当前 server span 上(FastAPI 自动 instrumentation 建的那个)
|
||||
|
||||
这些处理器是所有对外失败的**唯一出口**,也是链路上唯一还知道「异常长什么样」
|
||||
的地方:它们把异常吃掉换成 200/4xx 的信封响应,异常不再向上冒,自动
|
||||
instrumentation 只看得到一个 HTTP 状态码。尤其 AppError 默认 status_code=400、
|
||||
信封里 `success=false`,在 trace 里跟正常返回几乎分不出来——不在这里记一次,
|
||||
上游报「调用失败了」时链路里根本找不到对应的错误。
|
||||
|
||||
span 没在录(otel 关闭、或 /health 这类被 excluded_urls 排除的路径)时
|
||||
`set_attributes` / `record_error` 都作用在 NonRecordingSpan 上,是 noop,
|
||||
不必额外判断。
|
||||
"""
|
||||
span = trace.get_current_span()
|
||||
if exc is not None:
|
||||
record_error(span, exc)
|
||||
record_envelope(span, success=False, err_code=err_code, msg=msg, status_code=status_code)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""给应用挂上全套异常处理器
|
||||
|
||||
两个入口都调用它,保证抓取失败与下单失败返回的错误结构完全一致,
|
||||
上游只需要按 code 分支,不必区分是哪个服务回的。
|
||||
|
||||
每个处理器除了构造响应,还把这次失败记到当前 server span 上(见
|
||||
`_trace_failure`)——处理器是失败的唯一出口,不记就等于链路里没有这次失败。
|
||||
"""
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
||||
"""业务异常处理器:返回结构化的错误响应"""
|
||||
_trace_failure(
|
||||
exc, err_code=exc.err_code, msg=exc.message, status_code=exc.status_code
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ApiResponse[None](
|
||||
@@ -123,11 +152,15 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""请求参数校验异常处理器"""
|
||||
errors = exc.errors()
|
||||
msg = _format_validation_msg(errors)
|
||||
# 校验失败不记异常本身(pydantic 的 ValidationError 栈很长且没有诊断价值),
|
||||
# 只留错误码与整理后的字段消息——排查要看的是「哪个字段不合法」
|
||||
_trace_failure(None, err_code=1002, msg=msg, status_code=422)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=ApiResponse[object](
|
||||
success=False,
|
||||
msg=_format_validation_msg(errors),
|
||||
msg=msg,
|
||||
data=jsonable_errors(errors),
|
||||
code=1002,
|
||||
).model_dump(),
|
||||
@@ -137,11 +170,13 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
async def pydantic_validation_error_handler(_: Request, exc: ValidationError) -> JSONResponse:
|
||||
"""Pydantic 模型校验异常处理器"""
|
||||
errors = exc.errors()
|
||||
msg = _format_validation_msg(errors)
|
||||
_trace_failure(None, err_code=1002, msg=msg, status_code=422)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=ApiResponse[object](
|
||||
success=False,
|
||||
msg=_format_validation_msg(errors),
|
||||
msg=msg,
|
||||
data=jsonable_errors(errors),
|
||||
code=1002,
|
||||
).model_dump(),
|
||||
@@ -152,11 +187,13 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""HTTP 异常处理器(404、500 等)"""
|
||||
status_code = int(getattr(exc, "status_code", 500) or 500)
|
||||
err_code = 1404 if status_code == 404 else 1500
|
||||
detail = str(getattr(exc, "detail", "HTTP error"))
|
||||
_trace_failure(None, err_code=err_code, msg=detail, status_code=status_code)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=ApiResponse[None](
|
||||
success=False,
|
||||
msg=str(getattr(exc, "detail", "HTTP error")),
|
||||
msg=detail,
|
||||
data=None,
|
||||
code=err_code,
|
||||
).model_dump(),
|
||||
@@ -167,6 +204,10 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
async def unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底异常处理器:捕获所有未处理的异常"""
|
||||
logger.exception("未处理异常:%s", exc)
|
||||
# 这里最需要 record_error:对外只回一句无信息量的 "Internal server error",
|
||||
# 真正的异常类型与栈只在本进程日志里。记到 span 上,链路里就能直接看到
|
||||
# 是什么炸了,不必再去捞日志按时间对。
|
||||
_trace_failure(exc, err_code=1500, msg="Internal server error", status_code=500)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ApiResponse[None](
|
||||
|
||||
Reference in New Issue
Block a user