方案 A:轻量自研 ASGI 中间件 + 后台 worker 批量直采到本地 Docker OpenObserve。 仅 app-server(8770);默认关、opt-in;队列满丢弃、上报失败不重试、跳过 /health。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
接口 QPS + 耗时可观测(OpenObserve)设计
- 日期:2026-07-06
- 状态:已评审通过,待写实现计划
- 范围:仅 app-server(8770);admin(8771)暂不接入
- 方案:A —— 轻量自研 ASGI 中间件 + 后台 worker 批量直采到 OpenObserve
1. 背景与目标
app-server 目前除 CORS 外无任何中间件,也无接口级可观测。需要按每个接口采集:
- QPS(每秒请求数,可按接口/时间分桶)
- 耗时(P50/P95/P99 等分位)
顺带低成本拿到错误率(status >= 500 占比)。落地目标是:本地 Docker 跑一个 OpenObserve 实例接收数据,服务侧加埋点上报,在 OpenObserve 仪表盘上看各接口 QPS + 耗时。
非目标(YAGNI)
- 不做分布式 trace / span 关联(只要接口聚合指标)。
- 不引入 OpenTelemetry / Prometheus 客户端等重依赖。
- 不采集请求体 / query / 用户身份等,任何 PII 都不进上报。
- admin(8771)本期不接(中间件写成可复用,未来一行挂载即可)。
- 上报失败不做持久化重试 / 落盘补偿(best-effort)。
2. 方案选型
对比过三条路(详见评审记录):
- A 轻量自研中间件 + JSON 直采(选中):零新依赖(
httpx已在依赖里),完全贴合本仓库「后台 worker + JSON 事件 +*_configured优雅降级」的既有习惯,恰好满足「每接口 QPS + 耗时 + 错误率」并保留原始事件下钻能力。 - B OpenTelemetry 自动埋点 + OTLP:行业标准、顺带 trace,但多 5–6 个依赖、概念多、数据量/成本高于需求,与精简代码库风格相悖。
- C Prometheus 进程内聚合 + remote_write/抓取:数据量最小,但 remote_write 编码复杂或需额外抓取进程,丢失单请求下钻,最不贴合 OpenObserve 的 log-first 强项。
结论:A。
3. 架构与数据流
每个 HTTP 请求
→ RequestMetricsMiddleware(最外层:测总耗时 / 抓路由模板 + 状态码)
→ record_event() 非阻塞入队(有界队列,满则丢最旧,绝不阻塞、绝不 OOM)
→ observe_worker(后台 asyncio.Task,随 lifespan 启停)批量 drain
→ httpx POST {ENDPOINT}/api/{ORG}/{STREAM}/_json → OpenObserve
→ 仪表盘 SQL 聚合出 QPS / 分位耗时 / 错误率
核心不变量:
- 请求路径上只做「测时 + 构建一个小 dict +
put_nowait」,无任何网络/磁盘 I/O。 - 所有上报 I/O 在后台 worker;worker 捕获全部异常,绝不让埋点影响请求。
- 未配置观测(
observe_configured=False)→ 中间件透传、worker 不启动,整套 no-op。 - OpenObserve 不可用 → 队列填满后丢弃事件 + 限流告警,业务零影响。
4. 组件设计
4.1 OpenObserve 本地部署 —— deploy/openobserve/docker-compose.yml(新增)
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports: ["5080:5080"]
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local"
ZO_ROOT_USER_PASSWORD: "Complexpass#123"
ZO_DATA_DIR: "/data"
volumes: ["./data:/data"]
restart: unless-stopped
docker compose up -d启动;Web UIhttp://localhost:5080,用上面邮箱/密码登录。- 单容器 = local 模式,数据落
./data(已挂卷持久化)。 - stream 首次上报自动创建,无需预建
app_requests。 - 上报鉴权:HTTP Basic auth(
email:password),本地直接用 root 账号;生产应另建仅具 ingest 权限的用户/服务账号(本期不涉及)。
4.2 事件 schema(一请求一行 JSON)
{
"_timestamp": 1720000000000000, // 微秒(µs)整数,请求完成时刻。OpenObserve 默认时间列 _timestamp 以微秒计
"service": "app-server", // 取 LOG_SERVICE_NAME / 固定值
"env": "dev", // settings.APP_ENV
"method": "POST",
"route": "/api/v1/coupon/step", // 路由模板(非实际 path)
"status": 200,
"duration_ms": 42.7 // float 毫秒
}
- 只存路由模板(如
/c/{code}、/media静态归一),避免 path 参数把维度打爆。 - 未匹配路由(404 / 扫描器)归一到常量
__unmatched__。 - 只采 method / route / status / duration —— 无 body、无 query、无 PII。
4.3 埋点中间件 —— app/core/observe.py(新增)
纯 ASGI 中间件(比 BaseHTTPMiddleware 开销低;能可靠读到路由与最终状态码;scope 按引用透传,内层 router 的 scope["route"] 外层可见)。
职责:
- 非
http请求、或not settings.observe_configured→ 直接透传,不测。 perf_counter()记起点;包一层send抓http.response.start的status(默认兜底 500,覆盖下游抛异常未产出 response 的情况)。finally里算duration_ms,从scope取路由模板(见下),构建事件,调record_event()。- 跳过路径集合
_SKIP_PATHS = {"/health"}(纯噪音)。
路由模板解析(跨 Starlette 版本稳健):
route = scope.get("route")
template = getattr(route, "path", None)
if template is None: # 未匹配 / 老版本未写 scope["route"]
template = "__unmatched__"
(若实测某 Starlette 版本不写 scope["route"],回退用 request.app.router.routes 逐个 route.matches(scope)==Match.FULL 找模板;实现时以实际版本为准,优先 scope["route"]。)
入队(record_event):模块级 asyncio.Queue(maxsize=OBSERVE_QUEUE_MAX)。用 put_nowait,QueueFull 则丢弃并累加一个 _dropped 计数(每累计 N 条限流打一条 WARNING)。永不 await put()、永不阻塞请求。
决策(a):队列满 → 丢弃(不阻塞请求)。
4.4 上报 worker —— app/core/observe_worker.py(新增)
对齐现有 heartbeat_monitor_worker.py / daily_exchange_worker.py / withdraw_reconcile_worker.py 的 start_* / stop_* 形态。
start_observe_worker() -> asyncio.Task | Nonenot settings.observe_configured→ 返回None(no-op)。- 否则建专用
httpx.AsyncClient(base_url=ENDPOINT,auth=(USER, PASSWORD),timeout=OBSERVE_TIMEOUT_SEC),起_run_looptask。
_run_loop():循环_collect_batch():await asyncio.wait_for(queue.get(), timeout=FLUSH_INTERVAL)拿到首条(超时且空 → 返回空,continue);再get_nowait()连抽到BATCH_MAX条或抽空。POST /api/{ORG}/{STREAM}/_json,body 为事件数组。- catch 所有异常:失败限流打 WARNING,直接丢弃该批,不重试。
stop_observe_worker(task):best-effort 收尾 flush(短超时)→task.cancel()→await(吞CancelledError)→ 关 client。
决策(b):上报失败 → 直接丢弃,不重试(best-effort 遥测)。
4.5 配置 —— app/core/config.py(改)
新增一段 # ===== 可观测(OpenObserve 接口指标)=====,默认全关(prod 安全):
| 配置 | 默认 | 说明 |
|---|---|---|
OBSERVE_ENABLED |
False |
总开关;默认关,opt-in |
OBSERVE_ENDPOINT |
http://localhost:5080 |
OpenObserve base URL |
OBSERVE_ORG |
default |
组织名 |
OBSERVE_STREAM |
app_requests |
stream 名 |
OBSERVE_USER |
"" |
Basic auth 邮箱 |
OBSERVE_PASSWORD |
"" |
Basic auth 密码/token |
OBSERVE_FLUSH_INTERVAL_SEC |
5.0 |
worker 最长攒批间隔 |
OBSERVE_BATCH_MAX |
200 |
单批最大事件数 |
OBSERVE_QUEUE_MAX |
10000 |
有界队列上限,满则丢 |
OBSERVE_TIMEOUT_SEC |
5.0 |
上报 HTTP 超时 |
@property
def observe_configured(self) -> bool:
return bool(self.OBSERVE_ENABLED and self.OBSERVE_ENDPOINT
and self.OBSERVE_USER and self.OBSERVE_PASSWORD)
.env.example 同步补一段带注释的 OBSERVE_*(沿用该文件重注释风格),OBSERVE_ENABLED=false。
4.6 接线 —— app/main.py(改)
- import
RequestMetricsMiddleware、start_observe_worker/stop_observe_worker。 app.add_middleware(RequestMetricsMiddleware):放在 CORSadd_middleware之后 → 成为最外层,测到含 CORS 的完整耗时。无条件挂载(内部自 no-op)。lifespan:启动observe_task = start_observe_worker();finally里await stop_observe_worker(observe_task),与现有 worker 并列。
4.7 OpenObserve 查询 / 仪表盘 —— deploy/openobserve/README.md(新增)
含:compose 启停、登录、stream 自动创建说明、.env 接线,以及可直接粘的示例 SQL:
- 各接口 QPS(1 分钟分桶):
(面板按
SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt FROM app_requests GROUP BY route, ts ORDER BY tscnt/60展示每秒;或用 OpenObserve 图表的 rate 能力。) - 各接口 P95 耗时:
SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms FROM app_requests GROUP BY route ORDER BY p95_ms DESC - 各接口错误率:
SELECT route, count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct FROM app_requests GROUP BY route ORDER BY err_pct DESC
5. 关键设计决策汇总
- (a) 队列满 → 丢弃(不阻塞请求):遥测让路于业务可用性。
- (b) 上报失败 → 不重试:best-effort;避免 poison batch 堆积与队列无限增长。
- (c) 跳过
/health:健康检查是纯噪音,硬编码在_SKIP_PATHS。 - 只存路由模板 +
__unmatched__:防维度爆炸。 - 默认 OFF、opt-in:prod 安全默认;开启后仍全异步 + 有界。
- 纯 ASGI 中间件 +
perf_counter:请求路径开销微秒级,无 I/O。
6. 安全 / 性能保证
- 请求路径新增开销 ≈ 一次
perf_counter差 + 一个小 dict + 一次put_nowait(微秒级),无锁竞争的显著热点。 - 失败隔离:入队丢弃 + worker 全异常捕获;OpenObserve 宕机不影响任何请求。
- 有界内存:队列
maxsize封顶,最坏丢事件不涨内存。 - 无 PII:仅 method / route / status / duration。
7. 测试策略 —— tests/test_observe.py(新增)
沿用仓库约定(TestClient + monkeypatch,绝不打真网络;conftest 在 import 前设 env):
- 埋点入队字段正确:模板路由、
status、duration_ms > 0。 - 参数化路由 → 取到模板而非实际 path。
- 未匹配路径(404)→
route == "__unmatched__"。 OBSERVE_ENABLED=false→ 零入队、零 HTTP(现有测试不受影响)。- 队列满 →
record_event不抛异常(走丢弃分支)。 - worker 批量 POST 的 URL / payload 正确(monkeypatch httpx client /
_post,不打网络)。 /health被跳过 → 不入队。
settings是lru_cache单例;需要开启观测的用例通过 monkeypatchsettings属性或直接调record_event/ 中间件并 patchobserve_configured实现,避免全局 env 改动波及他用例。
8. 文件清单
| 文件 | 动作 |
|---|---|
deploy/openobserve/docker-compose.yml |
新增(OpenObserve 容器) |
deploy/openobserve/README.md |
新增(部署步骤 + 查询/仪表盘) |
app/core/observe.py |
新增(中间件 + 有界队列 + record_event + 路由解析) |
app/core/observe_worker.py |
新增(后台批量上报 worker) |
app/core/config.py |
改(OBSERVE_* + observe_configured) |
app/main.py |
改(挂中间件 + lifespan 启停 worker) |
.env.example |
改(新增 OBSERVE_* 注释段) |
tests/test_observe.py |
新增 |
9. 未来工作(本期不做)
- admin(8771)接入同一套中间件(
service字段区分)。 - 生产部署 OpenObserve(持久化、独立 ingest 账号、资源规格、鉴权收紧)。
- 上报字段扩展(如按 user/设备维度、上游 pricebot 透传耗时拆分)。