# 客户端运行日志批量上报 → 落专用文件 → 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 字节 | 2 MB | 413（依赖查 `Content-Length`，在 body 校验前拦截；缺该头由 nginx `client_max_body_size` 兜底） | `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 搞脏。

**「兜底」= 服务端强制、不信任客户端。** `logs` 以 `list[dict]` 原样收下（不强类型，单条异常不该让整批 422），在**写入层 `client_log.py`** 逐条拆分：白名单键提顶层，**其余键（含客户端自带的 `data`）统一并进 `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 ≤2MB；失败可重试（服务端接受重复）。

## 11. 代码落点

| 文件 | 职责 |
|---|---|
| `app/api/v1/applog.py` | 瘦路由：body 上限依赖 → 解析 `AppLogBatchIn` → 调 writer → 返回 `received/dropped`；`_client_ip` 复用 analytics 逻辑、IP 限流 |
| `app/schemas/applog.py` | `AppLogBatchIn`（`logs: list[dict]` 不强类型）/ `AppLogIngestOut` |
| `app/core/client_log.py` | 专用 logger/handler、滚动配置、白名单+`data` 兜底与信封拼装 `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` > 2MB → 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` | `2097152`(2MB) | 请求体上限 |
| `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 表关联。
