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:
@@ -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
|
||||
Reference in New Issue
Block a user