Files
rakuten-api/app/gateway/main.py
T
q792602257andClaude Opus 5 3c7618a1d6 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>
2026-08-28 14:56:49 +08:00

182 lines
7.2 KiB
Python

"""下单任务网关入口:FastAPI 应用创建与生命周期管理
部署在服务器侧,与抓取服务(:31107)、交易服务(:31108)并列。本地 worker 通过
出站长轮询从这里领任务;上游业务系统通过 POST /api/orders 提交下单意图。
为什么必须独立部署单元(不能塞进抓取服务):抓取无状态可多开,任务队列有状态,
多实例会抢同一批任务,同一账号的写操作必须串行(详见 docs/order-gateway.md §2)。
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.gateway.api.routes.account import router as account_router
from app.gateway.api.routes.health import router as health_router
from app.gateway.api.routes.orders import router as orders_router
from app.gateway.api.routes.queries import router as queries_router
from app.gateway.callback import CallbackNotifier
from app.gateway.collector import OrderDiscoveryCollector
from app.gateway.container import GatewayContainer
from app.gateway.db import GatewayDB
from app.gateway.query_queue import QueryQueue
from app.gateway.task_queue import TaskQueue
from app.shared.api import register_exception_handlers
from app.shared.config import get_settings
from app.shared.logging_setup import configure_logging
from app.shared.telemetry import instrument_app, setup_telemetry, shutdown_telemetry
logger = logging.getLogger(__name__)
SWEEP_INTERVAL_SECONDS = 60
def build_container() -> GatewayContainer:
"""构建网关容器:DB + 下单任务队列 + 账号只读查询队列 + 回调通知器"""
settings = get_settings()
db = GatewayDB(settings.gateway_db_path_resolved)
# 终结类事件回调(§4.8):best-effort 单次投递,失败只记日志
notifier = CallbackNotifier(
settings=settings, timeout_seconds=settings.callback_timeout_seconds
)
task_queue = TaskQueue(
db,
lease_ttl_seconds=settings.lease_ttl_seconds,
worker_offline_alert_seconds=settings.worker_offline_alert_seconds,
notifier=notifier,
)
query_queue = QueryQueue(
db,
lease_ttl_seconds=settings.query_lease_ttl_seconds,
query_ttl_seconds=settings.query_ttl_seconds,
max_attempts=settings.query_max_attempts,
retention_seconds=settings.query_retention_seconds,
)
collector = OrderDiscoveryCollector(
settings=settings, db=db, query_queue=query_queue
)
return GatewayContainer(
settings=settings, db=db, task_queue=task_queue,
query_queue=query_queue, collector=collector, notifier=notifier,
)
async def _sweep_loop(container: GatewayContainer) -> None:
"""常驻后台任务:每 60 秒扫一次过期租约
下单侧把 leased/running 推到 stale;查询侧重投超时的、置 expired 超 TTL 的、
清掉过保留期的结果。
lease 请求本身也会顺带扫一次,但 worker 退场后没人来 lease,必须靠这个
兜底——否则过期的任务永远停在 active 状态,健康检查看不到,stale 任务也
取不出来 reclaim。
"""
while True:
try:
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
swept = await container.task_queue.sweep()
if swept:
logger.info("sweep 把 %s 个过期任务置为 stale", swept)
query_swept = await container.query_queue.sweep()
if query_swept:
logger.info("sweep 处理了 %s 张超时/过期的查询单", query_swept)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
# 后台任务不能因为偶发错误退出,否则过期任务再也无人清理
logger.exception("sweep 后台任务出错,将继续重试")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期:启动 DB、起 sweep 后台任务、关闭时反序释放"""
container = build_container()
app.state.container = container
configure_logging(container.settings)
# 网关这一侧原先完全没接 telemetry,导致 worker 出站请求带过来的 traceparent
# 没人接上:一条下单链路在网关这里断掉,观测后台上只看得到 worker 侧那半截。
# CallbackNotifier 的 httpx 客户端是每次投递时才建的(不是 __init__ 里),
# 所以放在 build_container() 之后仍然赶在客户端创建之前。
setup_telemetry(container.settings, service_name="rakuten-gateway")
logger.info(
"网关启动:%s:%s", container.settings.gateway_host, container.settings.gateway_port
)
logger.info("当前环境:%s", container.settings.app_env)
logger.info("DB 路径:%s", container.settings.gateway_db_path_resolved)
await container.db.start()
# 启动时先扫一次:上次进程退出时可能留有 leased/running 的过期任务
startup_swept = await container.task_queue.sweep()
if startup_swept:
logger.warning("启动时把 %s 个遗留过期任务置为 stale", startup_swept)
startup_queries = await container.query_queue.sweep()
if startup_queries:
logger.warning("启动时处理了 %s 张遗留的超时/过期查询单", startup_queries)
container.sweep_task = asyncio.create_task(
_sweep_loop(container), name="gateway-sweep"
)
# 定时下派通道(§12):被启用时 collector.run() 里自带常驻循环,禁用则直接返回
assert container.collector is not None
container.collector_task = asyncio.create_task(
container.collector.run(), name="gateway-collector"
)
try:
yield
finally:
if container.collector_task is not None:
container.collector_task.cancel()
try:
await container.collector_task
except asyncio.CancelledError:
pass
container.collector_task = None
if container.sweep_task is not None:
container.sweep_task.cancel()
try:
await container.sweep_task
except asyncio.CancelledError:
pass
container.sweep_task = None
if container.notifier is not None:
# 等在途回调发完(各次发送有超时兜底),避免关停时静默丢通知
await container.notifier.aclose()
await container.db.close()
shutdown_telemetry()
def create_app() -> FastAPI:
"""创建 FastAPI 应用实例,注册路由和异常处理器"""
app = FastAPI(title="Rakuten Order Gateway", lifespan=lifespan)
app.include_router(health_router)
app.include_router(orders_router)
app.include_router(queries_router)
app.include_router(account_router)
register_exception_handlers(app)
instrument_app(app)
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
settings = get_settings()
configure_logging(settings)
uvicorn.run(
"app.gateway.main:app",
host=settings.gateway_host,
port=settings.gateway_port,
log_config=None,
timeout_keep_alive=120,
# 单进程:SQLite 单连接 + 全局并发度 1,多进程会抢同一个 DB
workers=1,
)