diff --git a/docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md b/docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md new file mode 100644 index 0000000..a8a2cf0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md @@ -0,0 +1,200 @@ +# 客户端运行日志批量上报 → 落专用文件 → SLS 采集 设计 + +- 日期:2026-07-19 +- 状态:设计已评审,待写实现计划 +- 相关代码:[app/core/logging.py](../../../app/core/logging.py)(服务端日志落盘范式)、[app/api/v1/analytics.py](../../../app/api/v1/analytics.py)(批量上报入口范式)、[deploy/shaguabijia-app-server.service](../../../deploy/shaguabijia-app-server.service)(`--workers 1`) + +## 1. 背景与目标 + +Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网络 / 崩溃 / 调试等),批量以 JSON 数组发给后端。后端把这些日志**逐条**写进一个**专用滚动日志文件**,文件滚动规则与服务日志一致;再由阿里云 SLS 的 Logtail 采集该文件、进独立 logstore。 + +目标: + +1. 新增 `POST /api/v1/applog/batch` 接收批量客户端日志。 +2. 逐条落进 `logs/app-client.log`(**独立于** `app-server.log`),单行 JSON、大小滚动、供 Logtail JSON 模式零正则采集。 +3. 客户端日志进**独立 logstore**,但 **可按 `trace_id` 检索**,以便和服务端比价链路跨层对齐。 +4. 入口安全:鉴权同 analytics(不强制 Bearer),加体积/条数上限、限流,防撑盘与日志注入。 + +本方案是两个**已验证范式的组合**:analytics 的「批量入口 + 服务端补 IP/接收时间」+ `app-server.log` 的「单行 JSON + RotatingFileHandler + Logtail」。不是新架构。 + +## 2. 非目标(本期不做,YAGNI) + +- **异步队列 / 后台 writer**:`<10 万条/天` ≈ 均值 1.2 条/秒,10× 突发 ~12/秒,请求路径同步写文件足矣。 +- **去重**:网络重试会在 SLS 产生重复条目。本期**接受重复**并在文档写明;后续如需,加客户端 `batch_id` 短窗口去重。 +- **服务端脱敏**:本期靠**客户端侧**控制等级/采样与不打 PII;服务端只做体积截断。脱敏留作后续 knob。 +- **多 worker 支持**:见 §5 的单 worker 约束。 +- **入库 / admin 查询界面**:日志的归宿是 SLS,不落 DB。 + +## 3. 方案总览与备选取舍 + +**采用 A**:客户端 → 后端接口 → 专用滚动文件 → Logtail → SLS。 + +| 方案 | 说明 | 为何不选 | +|---|---|---| +| **A(选定)** | 后端中转落文件,Logtail 采集 | — 代码最少、完全复用现成文件→Logtail 管线;AK/SK 不进 APK;Logtail 天然提供落盘缓冲+断点续传 | +| B | 客户端直连 SLS(Producer SDK / Web-Tracking) | 要么把凭证埋进 APK,要么额外跑 STS 换 token 服务;服务端难做鉴权/富化/脱敏 | +| C | 后端调 SLS PutLogs API(不落文件) | 请求路径硬依赖 SLS 可用性,需自建缓冲/背压/重试——等于重造 Logtail | + +## 4. 端点契约 + +`POST /api/v1/applog/batch` + +- 新路由 `app/api/v1/applog.py`,在 [app/main.py](../../../app/main.py) `import ... as applog_router` 并 `app.include_router(applog_router)`(紧挨 analytics)。 +- 鉴权同 analytics:**不强制 Bearer**,`user_id` 可选放 body;服务端补 `client_ip`(复用 analytics 里的 `_client_ip` 取 `X-Forwarded-For` 首段逻辑)与接收时间。 + +### 请求体 `AppLogBatchIn`(批级公共字段发一次,省带宽) + +| 字段 | 类型 | 必填 | 约束/说明 | +|---|---|---|---| +| `device_id` | str | 是 | `max_length=64`;限流/分组键 | +| `user_id` | int? | 否 | 可选,未登录态也采集 | +| `app_ver` | str? | 否 | `max_length=32` | +| `platform` | str? | 否 | `max_length=16`(android/ios/harmony) | +| `sent_at` | int? | 否 | 本批上报时刻 epoch ms | +| `logs` | list[dict] | 是 | `min_length=1, max_length=500`;**每条为对象**,逐条按 §5 契约处理 | + +`logs` 用 `list[dict[str, Any]]` 而非强类型列表:这是**尽力而为**的日志链路,单条内容异常不应让整批 422 失败。超过 500 条由 Pydantic `max_length` 触发 422(客户端应更小批)。 + +### 响应 `AppLogIngestOut` + +```jsonc +{ "ok": true, "received": 128, "dropped": 2 } +``` +- `received`:成功写入文件的条数。 +- `dropped`:服务端处理/序列化失败被跳过的条数(正常为 0,属异常兜底计数)。**oversize 的 `msg` 是截断而非丢弃**,不计入 dropped。 + +### 限额(防撑盘 / 注入 / DoS) + +| 限额 | 默认 | 超限行为 | env | +|---|---|---|---| +| 单批条数 | 500 | 422(Pydantic) | `APPLOG_MAX_BATCH` | +| body 字节 | 1 MB | 413(handler 查 `Content-Length`;缺该头时按已读字节数兜底截断) | `APPLOG_MAX_BODY_BYTES` | +| 单条 `msg` 字节 | 8192 | 截断 + 标记,不丢 | `APPLOG_MAX_MSG_BYTES` | + +同时**对齐 nginx `client_max_body_size`**(见 [deploy/nginx](../../../deploy/nginx/))避免反代先于应用截断。限流复用 `app/core/ratelimit.py`(项目现有 IP 固定窗口);具体挂法参照现有已限流写端点,测试环境 `RATE_LIMIT_ENABLED=false` 关闭。 + +## 5. 每条记录契约 + 白名单键 + `data` 兜底 + +**客户端每条日志的识别键(仅这些提到输出行顶层):** + +| 键 | 类型 | 说明 | +|---|---|---| +| `client_ts` | int | 端事件时间 epoch ms(与 analytics 命名一致) | +| `level` | str | 服务端归一化为大写;不做硬枚举拒绝(fire-and-forget) | +| `trace_id` | str? | **§6 的核心**:有服务端交互的日志带上当初 API 返回的 trace | +| `tag` | str? | 模块/分类,便于 SLS 过滤 | +| `msg` | str | 消息主体,超 `APPLOG_MAX_MSG_BYTES` 截断并加标记 | + +**其余任意自定义字段 → 服务端一律收进单个 `data` 对象。** + +**为什么这么设计(防 SLS 索引列爆炸):** Logtail JSON 模式下每个**顶层 key** 都会成为 logstore 一个可索引字段/列。若放任客户端往顶层写任意 key(甚至把动态 id 拼进 key),logstore 会长出成千上万个不同顶层列 → 索引成本膨胀、可能撞字段数上限、schema 混乱到无法建稳定 dashboard/告警,一个客户端 bug 就能把 logstore 搞脏。 + +**「兜底」= 服务端强制、不信任客户端。** Pydantic 模型对每条 `dict` 用 `extra='allow'` + 一个校验器:把**白名单外的顶层键统一挪进 `data`**(而非透传到顶层,也非静默丢弃——日志要保真)。于是**输出行顶层列恒定**,无论客户端怎么发。 + +这与现有 `JsonFormatter` 平铺 `phase/step/command/cost_ms` 同思路,区别:那些 extra 是**服务端可信有限**的键;客户端不可信无界,故只给一个 `data` 沙盒。 + +## 6. 落盘 writer + +新模块 `app/core/client_log.py`: + +- 惰性单例 `logging.getLogger("client")`,**`propagate=False`**(否则冒泡进 root 被 `app-server.log` 二次写入并污染)。 +- 独占一个 `RotatingFileHandler`,formatter 为 `%(message)s`——**不复用 root 的 `JsonFormatter`**(那会把已是 JSON 的行二次编码成字符串)。writer 自己拼信封 dict 后 `json.dumps(..., ensure_ascii=False, default=str)` 得到**一行**,`logger.info(line)` 写出。 +- 复用 `logging` 模块的 handler 锁保证多线程(uvicorn threadpool)并发写安全。 +- 幂等 setup(仿 `setup_logging` 的 `_CONFIGURED` 守卫)。 + +**滚动规则(与服务日志同机制 `RotatingFileHandler`,尺寸给客户端量级):** + +| 参数 | 默认 | env | +|---|---|---| +| 文件路径 | `logs/app-client.log` | `CLIENT_LOG_FILE` / `LOG_DIR` | +| `maxBytes` | 20 MB | `CLIENT_LOG_MAX_BYTES` | +| `backupCount` | 10 | `CLIENT_LOG_BACKUP_COUNT` | +| `service` 字段 | `app-client` | `CLIENT_LOG_SERVICE_NAME` | + +≈ 200 MB / ~2 天缓冲,给 Logtail 断线留余量(按 <10 万条/天、条均值估算)。**滚动文件不 gzip**——Logtail 读不了压缩包会丢数据。 + +> ⚠️ **单 worker 约束**:`RotatingFileHandler` 多进程并发 `doRollover()` 会损坏/丢日志。当前生产 `--workers 1`(与限流器/SMS 码/SQLite 写锁同一既有假设,见 [deploy/shaguabijia-app-server.service](../../../deploy/shaguabijia-app-server.service))故安全。**代码注释显式标注**:扩 worker 前必须换 `QueueHandler`→单写入者 或外部 logrotate(copytruncate)或写 stdout 交 journald 采集。 + +**每条输出行(写入 `app-client.log`):** + +```jsonc +{ "time": "2026-07-19T12:00:00.123", // 服务端接收时间 = SLS 主时间(客户端时钟不可信) + "source": "client", "service": "app-client", + "level": "ERROR", "trace_id": "abc123", "tag": "automation", "msg": "...", + "device_id": "d-xxx", "user_id": 123, "app_ver": "1.2.3", "platform": "android", + "client_ip": "1.2.3.4", "client_ts": 1737000000123, "sent_at": 1737000005000, + "data": { /* 客户端其余任意字段 */ } } +``` +- `time` 用服务端接收时间作 SLS 主时间;`client_ts` 保留为可查字段(时钟漂移不影响检索基准)。 +- 值为空的可选字段省略,保持行精简(仿服务端 formatter 省略空 `trace_id`)。 + +## 7. trace_id 检索(满足目标 #3) + +关键:**per-record 把 `trace_id` 提到输出行顶层,字段名与服务端日志完全一致(`trace_id`)**。来源是客户端在**它发起过服务端调用的那些日志**里带上当初 API 返回的 trace(如 `/api/v1/compare/*` 由 [pricebot_router](../../../app/core/pricebot_router.py) 透传的 trace)。没有服务端交互的纯客户端日志不带 `trace_id`,正常。 + +于是 SLS 里对客户端 logstore 与服务端 logstore 各查 `trace_id: "xxx"`,即可拼出「客户端自动化视角 + 服务端比价链路」的端到端故事。 + +> **需客户端配合**:给有服务端 trace 的日志记录打上 `trace_id`(Android 侧改动,见 §10)。 + +## 8. Logtail / SLS 侧配置(运维,非本仓代码) + +- 新建**独立 logstore**(独立保留期,客户端日志建议**比服务端短**以控成本)。 +- 新 Logtail 配置采集 `logs/app-client.log`,**JSON 模式**(每行一条 JSON,零正则,与 `app-server.log` 同套路)。 +- **把 `trace_id` 配成索引字段**(否则 #3 查不了);`level` / `tag` / `device_id` 亦建议建索引。 +- 时间字段用输出行 `time`(服务端接收时间)。 + +## 9. 失败语义 / 安全 + +- **fire-and-forget**:写文件失败 → 服务端记一笔(`shagua.applog` logger)+ 仍返回 2xx,**绝不 500**(对比 selfstat 的 503 是 DB 关键链路,日志不是;500 会招致客户端重试风暴+重复上报)。 +- **日志行注入防护**:一律 `json.dumps` 重新序列化,内嵌 `\n` 被转义,客户端伪造不出假日志行;绝不把客户端原始字符串直接写文件。 +- **撑盘/DoS**:§4 的条数/体积/msg 上限 + IP 限流;`device_id` 便于后续拉黑滥用设备。 + +## 10. 客户端契约(Android 侧需配合,属另一仓) + +1. 每条日志结构:顶层放 `client_ts / level / trace_id? / tag? / msg`,其余自定义字段放 `data`(否则会被服务端兜底挪进 `data`)。 +2. 有服务端交互的日志带上对应 `trace_id`。 +3. 等级/采样与 PII 控制在客户端侧做(省流量、免服务端脱敏)。 +4. 单批 ≤500 条、body ≤1MB;失败可重试(服务端接受重复)。 + +## 11. 代码落点 + +| 文件 | 职责 | +|---|---| +| `app/api/v1/applog.py` | 瘦路由:解析 `AppLogBatchIn` → 查 body 上限 → 调 writer → 返回 `received/dropped`;`_client_ip` 复用 analytics 逻辑 | +| `app/schemas/applog.py` | `AppLogBatchIn` / `AppLogIngestOut`;每条记录的白名单+`data` 兜底校验器 | +| `app/core/client_log.py` | 专用 logger/handler、滚动配置、信封拼装 `write_records(...)` | +| `app/main.py` | 注册 `applog_router` | + +## 12. 测试计划(`tests/test_applog.py`) + +用临时目录做 `CLIENT_LOG_FILE`(测试前置 env + 重置 writer 单例)。 + +1. **落盘逐行**:POST N 条 → 200,`received=N`,文件恰 N 行、每行合法 JSON、关键字段齐。 +2. **trace_id 顶层**:带 `trace_id` 的记录 → 输出行顶层出现 `trace_id`。 +3. **未知键兜底**:记录带 `foo` → 输出行顶层无 `foo`,`data.foo` 存在。 +4. **msg 截断**:`msg` > 8KB → 截断+标记,仍 `received`(不进 dropped)。 +5. **超批拒绝**:>500 条 → 422。 +6. **超体积拒绝**:`Content-Length` > 1MB → 413。 +7. **写失败不 500**:monkeypatch writer 抛错 → 仍 2xx。 +8. **不污染服务日志**:`client` logger `propagate=False`,写客户端日志不落 `app-server.log`。 + +## 13. env 变量汇总 + +| env | 默认 | 用途 | +|---|---|---| +| `CLIENT_LOG_FILE` | `logs/app-client.log` | 客户端日志文件路径 | +| `CLIENT_LOG_MAX_BYTES` | `20971520`(20MB) | 单文件滚动阈值 | +| `CLIENT_LOG_BACKUP_COUNT` | `10` | 保留滚动文件数 | +| `CLIENT_LOG_SERVICE_NAME` | `app-client` | 输出行 `service` 字段 | +| `APPLOG_MAX_BATCH` | `500` | 单批最大条数 | +| `APPLOG_MAX_BODY_BYTES` | `1048576`(1MB) | 请求体上限 | +| `APPLOG_MAX_MSG_BYTES` | `8192` | 单条 msg 截断阈值 | + +滚动/文件类 env 在 `client_log.py` 用 `os.getenv` 读取(与 [logging.py](../../../app/core/logging.py) 风格一致);请求限额类同样以 `os.getenv` 兜默认。 + +## 14. 已知取舍 / 未来工作 + +- **重复**:本期接受 SLS 重复条目;需要时加 `batch_id` 去重。 +- **多 worker**:见 §6 约束;扩容前迁移写入模型。 +- **服务端脱敏**:留作后续 knob。 +- **更多设备维度**(os/model/rom):需要时加到批级字段或让客户端放 `data`;可经 `device_id` 与 analytics/device 表关联。