test(observability): 补异常处理器的 server span 埋点测试

8381896 的最后一块配套测试。挂真实的 FastAPIInstrumentor 中间件(而不是手工造
span),断言「一次真实请求打进来、失败返回之后,server span 上有什么」:

- AppError 默认 status_code=400,链路里光看状态码只知道「客户端错了」,分不出
  是反爬阻断(3002,可重试)还是别的;断言 error.code / error.retryable /
  api.* 都落到了 span 上,异常栈作为 event 保留
- 兜底分支最需要埋点:对外只回 "Internal server error",断言真正的
  RuntimeError 类型与消息在链路上看得到
- 校验失败记错误码与字段消息,但不记 pydantic 那条没有诊断价值的长栈
- 一条成功路径对照,证明 ERROR 状态与 api.* 属性不是恒真

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 16:10:16 +08:00
co-authored by Claude Opus 5
parent 8403b9b586
commit fd7d89ab0a
+154
View File
@@ -0,0 +1,154 @@
"""对外失败响应的埋点测试:异常处理器是失败的唯一出口
`register_exception_handlers` 里的处理器把异常吃掉、换成 `ApiResponse` 信封返回,
异常不再向上冒——**自动 instrumentation 之后只看得到一个 HTTP 状态码**。而
`AppError` 默认 `status_code=400`、信封里 `success=false`,在链路上跟正常返回几乎
分不出来;兜底处理器更是只回一句无信息量的 "Internal server error",真正的异常
类型与栈只在本进程日志里。所以处理器必须把这次失败记到当前 server span 上。
这里挂真实的 FastAPIInstrumentor 中间件(而不是手工造 span),断言的就是「一次
真实请求打进来、失败返回之后,server span 上有什么」。
"""
from __future__ import annotations
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import StatusCode
from pydantic import BaseModel
from app.shared.api import ApiResponse, register_exception_handlers
from app.shared.errors import ItemNotFoundError, UpstreamBlockedError
class _Body(BaseModel):
keyword: str
@pytest.fixture
def app_and_spans():
"""挂了异常处理器 + 真实 OTel ASGI 中间件的最小应用
provider 显式传给 instrument_app,避免碰全局 provider(只允许设置一次,
测试间共享会互相污染)。
"""
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
router = APIRouter()
@router.get("/api/blocked")
async def blocked() -> ApiResponse[None]:
raise UpstreamBlockedError("Blocked while fetching: challenge page detected")
@router.get("/api/missing")
async def missing() -> ApiResponse[None]:
raise ItemNotFoundError("Page not found: https://item.rakuten.co.jp/x/y/")
@router.get("/api/boom")
async def boom() -> ApiResponse[None]:
raise RuntimeError("unexpected explosion")
@router.post("/api/validated")
async def validated(_body: _Body) -> ApiResponse[None]:
return ApiResponse[None](success=True, msg="success", data=None, code=0)
app = FastAPI()
app.include_router(router)
register_exception_handlers(app)
FastAPIInstrumentor.instrument_app(app, tracer_provider=provider)
try:
yield app, exporter
finally:
FastAPIInstrumentor.uninstrument_app(app)
def _server_span(exporter: InMemorySpanExporter):
spans = exporter.get_finished_spans()
assert spans, "没有产生 server span"
return spans[-1]
def test_app_error_is_recorded_on_server_span(app_and_spans):
"""业务异常:错误码、可重试、异常类型都要落在 span 上
AppError 默认 status_code=400,链路里光看状态码只知道「客户端错了」,
不知道是被反爬阻断(3002,可重试)还是别的什么。
"""
app, exporter = app_and_spans
with TestClient(app) as http:
response = http.get("/api/blocked")
assert response.status_code == 400
assert response.json()["code"] == 3002
span = _server_span(exporter)
assert span.status.status_code is StatusCode.ERROR
assert span.attributes["error.type"] == "UpstreamBlockedError"
assert span.attributes["error.code"] == 3002
# 上游据此判断该不该重试,是失败分类里最要紧的一位
assert span.attributes["error.retryable"] is True
assert span.attributes["api.success"] is False
assert span.attributes["api.code"] == 3002
assert "challenge page" in span.attributes["api.msg"]
# 异常栈作为 event 保留,需要细看时能展开
assert any(event.name == "exception" for event in span.events)
def test_not_found_records_its_own_code(app_and_spans):
"""404 类业务异常与阻断类要能按码区分(4004 不可重试)"""
app, exporter = app_and_spans
with TestClient(app) as http:
assert http.get("/api/missing").status_code == 404
span = _server_span(exporter)
assert span.attributes["error.code"] == 4004
assert span.attributes["error.retryable"] is False
def test_unhandled_exception_records_real_cause(app_and_spans):
"""兜底分支最需要埋点:对外只回 "Internal server error",真因只在日志里"""
app, exporter = app_and_spans
with TestClient(app, raise_server_exceptions=False) as http:
response = http.get("/api/boom")
assert response.status_code == 500
assert response.json()["msg"] == "Internal server error"
span = _server_span(exporter)
assert span.status.status_code is StatusCode.ERROR
# 响应体里查不到的真因,链路上能直接看到
assert span.attributes["error.type"] == "RuntimeError"
assert "unexpected explosion" in span.attributes["error.message"]
assert span.attributes["api.code"] == 1500
def test_validation_error_records_field_message(app_and_spans):
"""参数校验失败记错误码与字段消息,但不记 pydantic 那条没有诊断价值的长栈"""
app, exporter = app_and_spans
with TestClient(app) as http:
response = http.post("/api/validated", json={})
assert response.status_code == 422
assert response.json()["code"] == 1002
span = _server_span(exporter)
assert span.status.status_code is StatusCode.ERROR
assert span.attributes["api.code"] == 1002
# 排查要看的是「哪个字段不合法」
assert "keyword" in span.attributes["api.msg"]
assert "error.type" not in span.attributes
def test_success_leaves_server_span_ok(app_and_spans):
"""成功请求不被误标 ERROR——作为对照说明失败断言不是恒真"""
app, exporter = app_and_spans
with TestClient(app) as http:
assert http.post("/api/validated", json={"keyword": "switch"}).status_code == 200
span = _server_span(exporter)
assert span.status.status_code is not StatusCode.ERROR
assert "api.success" not in span.attributes
assert "error.type" not in span.attributes