feat(observability): 抓取去掉首页预热,交易补齐链路埋点

两个问题一起处理,都与「出站请求与可观测性」有关。

## 抓取:正常路径不再多打一次首页

site_session 原先每条通道每 30 分钟打一次 www.rakuten.co.jp/ 做预热,而且预热
返回非 2xx 时 warmed_at 不置位——那种情况下每个请求前都会再打一次首页。

Akamai 的 cookie 随任意页面响应下发,目标页自己就会带回来,专门先打一次首页除了
多一个出站请求(以及多一次被风控计数的机会)之外没有额外收益:首个请求无论打哪个
URL 都是冷的 ~11s,之后都复用 cookie。

改为 cookie 由目标页响应建立(_note_cookies)、超 TTL 主动清空
(_drop_expired_cookies)。首页只保留在失败修复路径上(_rewarm_on_home):目标页
已经吃了挑战页时,拿首页换一套干净 cookie 比继续撞同一个 URL 更安全。happy path
的出站请求数 2 → 1。

_note_cookies 刻意不在每次响应时刷新时刻:TTL 要从「这套 cookie 第一次出现」算起,
每次都刷新会让一套 cookie 被无限续命,反而绕过了 session_ttl_seconds 的本意。

profile_status() 的 warmed 字段名保留(上游健康检查看板在用),语义改为「当前有
可复用的 Akamai cookie」,不再代表「已专门预热过首页」。

## 交易:此前没有任何有意义的链路数据

根因是 trading 的实际工作两类自动埋点都覆盖不到:站点交互走 Playwright(不经
httpx),worker 主循环是后台 asyncio 任务(没有 HTTP 入口,因此没有根 span)。
于是发给网关的每次 httpx 调用各自成为孤立 trace——观测后台上只剩一堆请求记录。

新增手工埋点:

- order.task:一笔下单的根 span,一个 task_id 一条 trace,带 order.route
  (execute / recovery / already_finished)与终态 order.terminal_status
- order.step.*:清车 → 加购 → 校验 → 确认 → 提交 → 付款,每步一个子 span,
  带 order.evidence_ref,可从 span 直接定位落盘证据
- site.*:12 个 Playwright 交互方法(用 traced 装饰器而非 with 块——这些方法的
  函数体本就很长,再加一层缩进不利于阅读)
- account_query:只读查询单的根 span,带 query.outcome

空转的长轮询(30 秒一次、绝大多数返回空)用 suppressed() 屏蔽:量大且没有信息量,
把观测后台刷满的正是它们。领到任务后的网关调用都在任务根 span 底下,不受影响。

闸门 / 风控拦截会被 _execute_with_renewal 吞掉转 needs_human,异常冒不到根 span,
被拦下的单在 trace 里跟成功下单一模一样。加 _execute_recording_errors 一层统一
记录,比每个 except 分支各写一遍省事,也不会漏掉后续新增的分支。

_report_safe 写 span 属性前判断 is_recording():付款后监控是 create_task 起的,
asyncio 在创建时就把 context 复制了进去,等它真正跑起来根 span 早已结束——
get_current_span() 拿到的仍是那个已结束的 span(不是 INVALID_SPAN),写属性会打
"Setting attribute on ended span"。当前监控路径不传 terminal_status 走不到那里,
这道判断是防以后。

## 顺带修掉:instrument_app 从未生效

instrument_app 用 _provider is None 做前置判断,但三个服务都在模块导入时执行
app = create_app(),而 setup_telemetry 要等 lifespan 才跑——那时 _provider 还是
None,照着判断直接 return。**FastAPI 从来没被打桩过,三个服务一条 server span
都没有。**

实测确认两件事:导入期打桩能出 span,lifespan 内打桩出不来(instrument_app 是加
中间件,应用开始服务后加进去不生效);provider 后设也不影响 ProxyTracer 委托到
真实 provider。所以只能在导入期装,判断条件改为 otel_enabled。

app/gateway/main.py 此前完全没接 telemetry,worker 出站请求带过来的 traceparent
没人接上,一条下单链路在网关这里断掉,只看得到 worker 侧那半截。补上
setup_telemetry(service_name="rakuten-gateway") 与 instrument_app / shutdown。

## 验证

新增 8 个用例:首页零请求、cookie 复用与过期清空、失败后用首页换 cookie、一任务
一 trace 的父子结构、闸门失败标 ERROR、空转不埋点,以及 instrument_app 调用顺序
的回归测试。全量 526 passed。

Playwright 那些 site.* 埋点只做了静态验证(测试用桩替换站点方法),没有跑真实
浏览器下单确认 span 真的落地。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 14:56:49 +08:00
co-authored by Claude Opus 5
parent bba45d7f7c
commit 3c7618a1d6
12 changed files with 727 additions and 113 deletions
+111 -2
View File
@@ -1,22 +1,47 @@
"""telemetry 模块测试
验证两件事
验证:
1. 默认配置(otel_enabled=False)下 setup 是 noop,不初始化任何 provider。
2. enabled=true + endpoint 时 setup 注册真实 TracerProvider;shutdown 复位。
3. instrument_app 在 setup 之前调用也要真的装上中间件(三个服务都是导入期打桩)。
4. traced / set_attributes / record_error 的行为。
不打真实网络:OTLPSpanExporter 创建时不发请求,BatchSpanProcessor 异步批量
上报在没有 span 产生时也不会触发。
上报在没有 span 产生时也不会触发。span 断言用独立的 InMemory provider,不碰
全局 provider——OTel 的全局 provider 只允许设置一次,测试间共享会互相污染。
"""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
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 app.shared import telemetry
from app.shared.config import Settings
from app.shared.errors import OrderGuardError
from app.shared.telemetry import is_initialized, setup_telemetry, shutdown_telemetry
@pytest.fixture
def spans(monkeypatch) -> InMemorySpanExporter:
"""把 telemetry 内部取到的 tracer 换成写内存的,用于断言 span
不用全局 provider:`trace.set_tracer_provider` 只生效一次,一旦别的用例先
设过,这里再设就被忽略(OTel 只打一条 warning),断言会莫名其妙拿不到 span。
"""
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
monkeypatch.setattr(telemetry.trace, "get_tracer", provider.get_tracer)
return exporter
def test_disabled_is_noop():
"""otel_enabled=False 时 setup/instrument/shutdown 都不初始化 provider"""
settings = Settings(_env_file=None) # 默认 otel_enabled=False
@@ -24,6 +49,90 @@ def test_disabled_is_noop():
assert not is_initialized()
def test_instrument_app_works_before_setup_telemetry(monkeypatch):
"""回归:instrument_app 必须在 setup_telemetry 之前也能真的装上中间件
三个服务都在模块导入时执行 `app = create_app()`(内部调 instrument_app),
而 setup_telemetry 要等 lifespan 才跑。曾经这里用 `_provider is None` 做前置
判断,于是导入期一律 return,FastAPI 从来没被打桩过——一条 server span 都
没有。这条用例把那个顺序钉住。
"""
monkeypatch.setattr(
telemetry, "get_settings", lambda: Settings(_env_file=None, otel_enabled=True)
)
assert not is_initialized() # 尚未 setup,正是导入期的状态
app = FastAPI()
try:
telemetry.instrument_app(app)
assert app._is_instrumented_by_opentelemetry
finally:
FastAPIInstrumentor.uninstrument_app(app)
def test_instrument_app_skipped_when_otel_disabled(monkeypatch):
"""otel 关闭时不装中间件,省掉一层用不上的开销"""
monkeypatch.setattr(
telemetry, "get_settings", lambda: Settings(_env_file=None, otel_enabled=False)
)
app = FastAPI()
telemetry.instrument_app(app)
assert not getattr(app, "_is_instrumented_by_opentelemetry", False)
async def test_traced_records_span_and_reraises(spans):
"""traced 成功时留一个 span;异常时记 ERROR 状态并原样抛出"""
@telemetry.traced("unit.ok")
async def ok() -> str:
return "done"
@telemetry.traced("unit.boom")
async def boom() -> None:
raise OrderGuardError("金额超限")
assert await ok() == "done"
with pytest.raises(OrderGuardError):
await boom()
finished = {s.name: s for s in spans.get_finished_spans()}
assert finished["unit.ok"].status.status_code is not StatusCode.ERROR
failed = finished["unit.boom"]
assert failed.status.status_code is StatusCode.ERROR
# AppError 的对外错误码要落在 span 上:排查时按码筛比按异常类名筛更贴近上游
assert failed.attributes["error.type"] == "OrderGuardError"
assert failed.attributes["error.code"] == OrderGuardError("x").err_code
async def test_traced_nests_under_caller_span(spans):
"""traced 出来的 span 要挂在调用方的 span 底下,而不是各自成为孤立 trace"""
@telemetry.traced("unit.child")
async def child() -> None:
return None
tracer = telemetry.trace.get_tracer(__name__)
with tracer.start_as_current_span("unit.root"):
await child()
by_name = {s.name: s for s in spans.get_finished_spans()}
assert by_name["unit.child"].parent.span_id == by_name["unit.root"].context.span_id
# 同一条 trace 才能在观测后台里连成一条链路
assert by_name["unit.child"].context.trace_id == by_name["unit.root"].context.trace_id
def test_set_attributes_skips_none(spans):
"""可选字段为 None 时不落属性,避免一堆 None 噪声"""
tracer = telemetry.trace.get_tracer(__name__)
with tracer.start_as_current_span("unit.attrs") as span:
telemetry.set_attributes(span, {"a": 1, "b": None, "c": "x"})
attributes = spans.get_finished_spans()[0].attributes
assert attributes["a"] == 1
assert attributes["c"] == "x"
assert "b" not in attributes
def test_enabled_initializes_and_shutdown_releases():
"""enabled=true 时 setup 注册 TracerProvider,shutdown 后 _provider 复位"""
settings = Settings(