Files
shaguabijia-app-server/docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md
T
guke 29aa6fbff8 docs: OpenObserve 接口 QPS/耗时可观测设计 spec
方案 A:轻量自研 ASGI 中间件 + 后台 worker 批量直采到本地 Docker OpenObserve。
仅 app-server(8770);默认关、opt-in;队列满丢弃、上报失败不重试、跳过 /health。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:50:18 +08:00

12 KiB
Raw Blame History

接口 QPS + 耗时可观测(OpenObserve)设计

  • 日期2026-07-06
  • 状态:已评审通过,待写实现计划
  • 范围:仅 app-server8770);admin8771)暂不接入
  • 方案: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 / 分位耗时 / 错误率

核心不变量

  1. 请求路径上只做「测时 + 构建一个小 dict + put_nowait」,无任何网络/磁盘 I/O
  2. 所有上报 I/O 在后台 worker;worker 捕获全部异常,绝不让埋点影响请求。
  3. 未配置观测(observe_configured=False)→ 中间件透传、worker 不启动,整套 no-op。
  4. 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 UI http://localhost:5080,用上面邮箱/密码登录。
  • 单容器 = local 模式,数据落 ./data(已挂卷持久化)。
  • stream 首次上报自动创建,无需预建 app_requests
  • 上报鉴权:HTTP Basic authemail: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"] 外层可见)。

职责:

  1. http 请求、或 not settings.observe_configured → 直接透传,不测。
  2. perf_counter() 记起点;包一层 sendhttp.response.startstatus(默认兜底 500,覆盖下游抛异常未产出 response 的情况)。
  3. finally 里算 duration_ms,从 scope 取路由模板(见下),构建事件,调 record_event()
  4. 跳过路径集合 _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_nowaitQueueFull 则丢弃并累加一个 _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.pystart_* / stop_* 形态。

  • start_observe_worker() -> asyncio.Task | None
    • not settings.observe_configured → 返回 Noneno-op)。
    • 否则建专用 httpx.AsyncClientbase_url=ENDPOINTauth=(USER, PASSWORD)timeout=OBSERVE_TIMEOUT_SEC),起 _run_loop task。
  • _run_loop():循环
    1. _collect_batch()await asyncio.wait_for(queue.get(), timeout=FLUSH_INTERVAL) 拿到首条(超时且空 → 返回空,continue);再 get_nowait() 连抽到 BATCH_MAX 条或抽空。
    2. POST /api/{ORG}/{STREAM}/_jsonbody 为事件数组。
    3. 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 RequestMetricsMiddlewarestart_observe_worker / stop_observe_worker
  • app.add_middleware(RequestMetricsMiddleware):放在 CORS add_middleware 之后 → 成为最外层,测到含 CORS 的完整耗时。无条件挂载(内部自 no-op)。
  • lifespan:启动 observe_task = start_observe_worker()finallyawait stop_observe_worker(observe_task),与现有 worker 并列。

4.7 OpenObserve 查询 / 仪表盘 —— deploy/openobserve/README.md(新增)

含:compose 启停、登录、stream 自动创建说明、.env 接线,以及可直接粘的示例 SQL

  • 各接口 QPS1 分钟分桶):
    SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt
    FROM app_requests GROUP BY route, ts ORDER BY ts
    
    (面板按 cnt/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):

  1. 埋点入队字段正确:模板路由、statusduration_ms > 0
  2. 参数化路由 → 取到模板而非实际 path。
  3. 未匹配路径(404)→ route == "__unmatched__"
  4. OBSERVE_ENABLED=false → 零入队、零 HTTP(现有测试不受影响)。
  5. 队列满 → record_event 不抛异常(走丢弃分支)。
  6. worker 批量 POST 的 URL / payload 正确(monkeypatch httpx client / _post,不打网络)。
  7. /health 被跳过 → 不入队。

settingslru_cache 单例;需要开启观测的用例通过 monkeypatch settings 属性或直接调 record_event / 中间件并 patch observe_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 透传耗时拆分)。