"""对外失败响应的埋点测试:异常处理器是失败的唯一出口 `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