From ab12b2eab1d290d549962ec67cc35a41fd330dbe Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 00:44:50 +0800 Subject: [PATCH 01/12] =?UTF-8?q?docs(applog):=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E8=BF=90=E8=A1=8C=E6=97=A5=E5=BF=97=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E2=86=92=E8=90=BD=E6=96=87=E4=BB=B6=E2=86=92?= =?UTF-8?q?SLS=20=E9=87=87=E9=9B=86=20=E8=AE=BE=E8=AE=A1(spec)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /api/v1/applog/batch:批量接收客户端运行日志,逐条写独立滚动 文件 logs/app-client.log(propagate=False,不污染 app-server.log),供 Logtail JSON 模式采进独立 logstore。每条按「白名单键(client_ts/level/trace_id/tag/ msg)+ data 兜底」封装以钉死 SLS 索引列;trace_id 提到顶层以跨层检索。 鉴权同 analytics、加条数/体积/msg 上限与 IP 限流、fire-and-forget 不 500。 滚动 20MB×10,单 worker 约束(RotatingFileHandler 多进程不安全)已标注。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-19-client-applog-ingest-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md 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 表关联。 -- 2.52.0 From fc21ab95ccaa7b11a5629c2d0aaedc86c889a91b Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 00:56:43 +0800 Subject: [PATCH 02/12] =?UTF-8?q?docs(applog):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=AE=A1=E5=88=92=20+=20spec=20=E5=AF=B9=E9=BD=90(=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E6=8B=86=E5=88=86=E8=90=BD=E5=86=99=E5=85=A5?= =?UTF-8?q?=E5=B1=82/=E4=BD=93=E7=A7=AF=E8=B5=B0=E4=BE=9D=E8=B5=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 plans/2026-07-19-client-applog-ingest.md:TDD 任务(writer→端点→env 文档),含完整代码。 - spec 对齐两处实现决策:①白名单+data 兜底在 client_log 写入层对 list[dict] 拆分 (而非 Pydantic 校验器),使单条异常不整批 422;②body 上限走依赖查 Content-Length (body 校验前拦截),缺头由 nginx 兜底。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-19-client-applog-ingest.md | 561 ++++++++++++++++++ .../2026-07-19-client-applog-ingest-design.md | 10 +- 2 files changed, 566 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-19-client-applog-ingest.md diff --git a/docs/superpowers/plans/2026-07-19-client-applog-ingest.md b/docs/superpowers/plans/2026-07-19-client-applog-ingest.md new file mode 100644 index 0000000..cb26eb0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-client-applog-ingest.md @@ -0,0 +1,561 @@ +# 客户端运行日志批量上报 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 新增 `POST /api/v1/applog/batch`,把客户端批量上报的运行日志逐条写进独立滚动文件 `logs/app-client.log`,供 Aliyun Logtail 采进独立 SLS logstore。 + +**Architecture:** 复用两个现成范式——analytics 的「批量入口 + 服务端补 IP」+ `app-server.log` 的「单行 JSON + RotatingFileHandler + Logtail」。核心逻辑在写入层 `app/core/client_log.py`:专用 `client` logger(`propagate=False`,不污染服务日志),每条按「白名单键提顶层 + 其余并入 `data`」封装成一行 JSON,`trace_id` 提到顶层以便跨层检索。端点瘦、fire-and-forget(写失败不 500)。 + +**Tech Stack:** FastAPI、Pydantic v2、Python `logging.handlers.RotatingFileHandler`、pytest + `TestClient`。 + +**Spec:** [docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md](../specs/2026-07-19-client-applog-ingest-design.md) + +--- + +## File Structure + +| 文件 | 职责 | 动作 | +|---|---|---| +| `app/core/client_log.py` | 专用 logger/handler、滚动配置、白名单+`data` 兜底与信封拼装、`write_records()` | Create | +| `app/schemas/applog.py` | `AppLogBatchIn`(`logs: list[dict]`)/ `AppLogIngestOut` | Create | +| `app/api/v1/applog.py` | 瘦路由:body 上限依赖 → 调 writer → 返回计数 | Create | +| `app/main.py` | 注册 `applog_router` | Modify | +| `tests/test_applog.py` | writer 单测 + 端点集成测试 | Create | + +前置约束(写进代码注释):`RotatingFileHandler` 多进程并发 `doRollover()` 会损坏/丢日志;本方案依赖生产 `--workers 1`(与限流器/SMS 码/SQLite 同一既有假设)。扩 worker 前必须换 `QueueHandler`→单写入者 / 外部 logrotate / 写 stdout 交 journald。 + +--- + +## Task 1: 专用落盘 writer `app/core/client_log.py` + +**Files:** +- Create: `app/core/client_log.py` +- Test: `tests/test_applog.py` + +- [ ] **Step 1: 写失败测试(writer 层)** + +创建 `tests/test_applog.py`: + +```python +"""客户端运行日志上报:writer 单测 + 端点集成测试。""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from app.core import client_log + + +@pytest.fixture() +def client_log_file(tmp_path, monkeypatch): + """把客户端日志切到临时文件,并重置 writer 单例使其按当时 env 重建。""" + p = tmp_path / "app-client.log" + monkeypatch.setenv("CLIENT_LOG_FILE", str(p)) + client_log.reset_client_logger() + yield p + client_log.reset_client_logger() + + +def _read_lines(p: Path) -> list[dict]: + text = p.read_text(encoding="utf-8").strip() + return [json.loads(ln) for ln in text.splitlines() if ln] + + +# ---------------- writer 层 ---------------- + +def _meta(**kw) -> dict: + base = {"device_id": "d-1", "user_id": None, "app_ver": None, + "platform": None, "sent_at": None} + base.update(kw) + return base + + +def test_writer_writes_one_line_per_record(client_log_file): + recs = [ + {"client_ts": 1737000000000, "level": "info", "msg": "hello"}, + {"client_ts": 1737000000001, "level": "error", "msg": "boom", "tag": "net"}, + ] + received, dropped = client_log.write_records( + recs, meta=_meta(user_id=42, app_ver="1.2.3", platform="android"), + client_ip="1.2.3.4", + ) + assert (received, dropped) == (2, 0) + lines = _read_lines(client_log_file) + assert len(lines) == 2 + assert lines[0]["source"] == "client" + assert lines[0]["service"] == "app-client" + assert lines[0]["device_id"] == "d-1" + assert lines[0]["user_id"] == 42 + assert lines[0]["app_ver"] == "1.2.3" + assert lines[0]["client_ip"] == "1.2.3.4" + assert lines[0]["level"] == "INFO" # 归一化大写 + assert lines[0]["msg"] == "hello" + assert lines[0]["client_ts"] == 1737000000000 + assert lines[1]["tag"] == "net" + + +def test_writer_hoists_trace_id_to_top_level(client_log_file): + client_log.write_records( + [{"client_ts": 1, "level": "info", "msg": "x", "trace_id": "abc123"}], + meta=_meta(), client_ip="", + ) + assert _read_lines(client_log_file)[0]["trace_id"] == "abc123" + + +def test_writer_sweeps_unknown_keys_into_data(client_log_file): + client_log.write_records( + [{"client_ts": 1, "level": "info", "msg": "x", + "foo": 123, "data": {"bar": "baz"}}], + meta=_meta(), client_ip="", + ) + line = _read_lines(client_log_file)[0] + assert "foo" not in line # 白名单外不进顶层 + assert line["data"]["foo"] == 123 # 兜底进 data + assert line["data"]["bar"] == "baz" # 客户端自带 data 合并进来 + + +def test_writer_truncates_oversize_msg(client_log_file, monkeypatch): + monkeypatch.setenv("APPLOG_MAX_MSG_BYTES", "10") + received, dropped = client_log.write_records( + [{"client_ts": 1, "level": "info", "msg": "x" * 100}], + meta=_meta(), client_ip="", + ) + assert (received, dropped) == (1, 0) # 截断而非丢弃 + line = _read_lines(client_log_file)[0] + assert line["msg_truncated"] is True + assert line["msg"].endswith("…[truncated]") + + +def test_writer_logger_does_not_propagate(client_log_file): + lg = client_log.get_logger() + assert lg.name == "client" + assert lg.propagate is False # 不冒泡到 root → 不写 app-server.log +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pytest tests/test_applog.py -q` +Expected: FAIL —— `AttributeError: module 'app.core.client_log' has no attribute 'reset_client_logger'`(模块尚不存在)。 + +- [ ] **Step 3: 实现 `app/core/client_log.py`** + +```python +"""客户端运行日志专用落盘 writer(独立于服务端 app-server.log)。 + +- 独占 logger "client" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。 +- 每条按「白名单键(client_ts/level/trace_id/tag/msg)提顶层 + 其余并入 data」封装,再 + json.dumps 成一行写出(钉死 SLS 索引列;见 spec §5)。formatter 用 %(message)s——行本身 + 已是 JSON,不能再过 JsonFormatter 二次编码。 +- 滚动 20MB×10(env 可调),与服务日志同机制。 + ⚠️ 依赖 --workers 1:RotatingFileHandler 多进程并发 doRollover 会损坏/丢日志;扩 worker + 前换 QueueHandler→单写入者 / 外部 logrotate(copytruncate) / 写 stdout 交 journald。 + +服务端补的字段(time/source/service/client_ip/device_id/...)是「事实」,与客户端自述分开。 +`time` 用服务端接收时间作 SLS 主时间(客户端时钟不可信),client_ts 另存为可查字段。 +""" +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime +from logging.handlers import RotatingFileHandler +from pathlib import Path + +# 仅这些客户端键提到输出行顶层;其余(含客户端自带 data)一律并入 data,防 SLS 索引列爆炸 +_TOP_LEVEL_KEYS = ("client_ts", "level", "trace_id", "tag", "msg") + +_logger: logging.Logger | None = None + + +def _max_msg_bytes() -> int: + return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192")) + + +def _build_logger() -> logging.Logger: + lg = logging.getLogger("client") + lg.setLevel(logging.INFO) + lg.propagate = False # 不冒泡到 root → 不写进 app-server.log + log_file = os.getenv("CLIENT_LOG_FILE") or str( + Path(os.getenv("LOG_DIR", "logs")) / "app-client.log" + ) + Path(log_file).parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + log_file, + maxBytes=int(os.getenv("CLIENT_LOG_MAX_BYTES", str(20 * 1024 * 1024))), + backupCount=int(os.getenv("CLIENT_LOG_BACKUP_COUNT", "10")), + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) # 行已是 JSON,不再包装 + lg.handlers = [handler] + return lg + + +def get_logger() -> logging.Logger: + global _logger + if _logger is None: + _logger = _build_logger() + return _logger + + +def reset_client_logger() -> None: + """测试用:关闭并丢弃当前 logger,使下次 get_logger 按当时 env 重建(切临时文件)。""" + global _logger + if _logger is not None: + for h in list(_logger.handlers): + h.close() + _logger.handlers = [] + _logger = None + + +def _truncate_msg(msg: str) -> tuple[str, bool]: + raw = msg.encode("utf-8") + limit = _max_msg_bytes() + if len(raw) <= limit: + return msg, False + # 按字节截断后解码,忽略截断处半个多字节字符 + return raw[:limit].decode("utf-8", "ignore") + "…[truncated]", True + + +def _build_line( + record: dict, *, meta: dict, client_ip: str, service: str, now_iso: str +) -> str: + out: dict = { + "time": now_iso, + "source": "client", + "service": service, + "client_ip": client_ip, + } + # 批级公共字段(非空才带) + for k in ("device_id", "user_id", "app_ver", "platform", "sent_at"): + v = meta.get(k) + if v is not None: + out[k] = v + # 白名单键提顶层 + if record.get("level") is not None: + out["level"] = str(record["level"]).upper() + if record.get("trace_id"): + out["trace_id"] = record["trace_id"] + if record.get("tag"): + out["tag"] = record["tag"] + if record.get("client_ts") is not None: + out["client_ts"] = record["client_ts"] + if record.get("msg") is not None: + msg, truncated = _truncate_msg(str(record["msg"])) + out["msg"] = msg + if truncated: + out["msg_truncated"] = True + # 其余键(含客户端自带 data)并入 data + data: dict = {} + client_data = record.get("data") + if isinstance(client_data, dict): + data.update(client_data) + for k, v in record.items(): + if k in _TOP_LEVEL_KEYS or k == "data": + continue + data[k] = v + if data: + out["data"] = data + return json.dumps(out, ensure_ascii=False, default=str) + + +def write_records( + records: list[dict], *, meta: dict, client_ip: str +) -> tuple[int, int]: + """把一批客户端日志逐条写入专用文件。返回 (received, dropped)。 + + 尽力而为(fire-and-forget):logger 初始化或单条写入失败只跳过并计 dropped, + 不抛给上层——端点因此永不因写日志而 500。 + """ + try: + lg = get_logger() + except Exception: # noqa: BLE001 — 初始化失败也不能让端点 500 + logging.getLogger("shagua.applog").exception("client log writer init failed") + return 0, len(records) + service = os.getenv("CLIENT_LOG_SERVICE_NAME", "app-client") + now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + received = dropped = 0 + for rec in records: + try: + line = _build_line( + rec, meta=meta, client_ip=client_ip, service=service, now_iso=now_iso + ) + lg.info(line) + received += 1 + except Exception: # noqa: BLE001 — 坏条跳过,不影响其余 + dropped += 1 + return received, dropped +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pytest tests/test_applog.py -q` +Expected: PASS(5 个 writer 测试全绿)。 + +- [ ] **Step 5: ruff** + +Run: `ruff check app/core/client_log.py tests/test_applog.py` +Expected: 无错误(如有 import 排序等自动可修问题:`ruff check --fix`)。 + +- [ ] **Step 6: 提交** + +```bash +git add app/core/client_log.py tests/test_applog.py +git commit -m "feat(applog): 客户端日志专用落盘 writer(白名单+data 兜底, propagate=False)" +``` + +--- + +## Task 2: 端点 + schema + 注册 + +**Files:** +- Create: `app/schemas/applog.py` +- Create: `app/api/v1/applog.py` +- Modify: `app/main.py`(import + `include_router`) +- Test: `tests/test_applog.py`(追加端点用例) + +- [ ] **Step 1: 追加失败测试(端点层)** + +在 `tests/test_applog.py` 末尾追加: + +```python +# ---------------- 端点层 ---------------- + +def _post(client, body): + return client.post("/api/v1/applog/batch", json=body) + + +def test_endpoint_happy_path(client, client_log_file): + body = { + "device_id": "d-1", "user_id": 42, "app_ver": "1.2.3", "platform": "android", + "logs": [ + {"client_ts": 1, "level": "info", "msg": "a", "trace_id": "t1"}, + {"client_ts": 2, "level": "warn", "msg": "b"}, + ], + } + resp = _post(client, body) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "received": 2, "dropped": 0} + lines = _read_lines(client_log_file) + assert len(lines) == 2 + assert lines[0]["trace_id"] == "t1" # 端到端:trace_id 落到文件顶层 + assert lines[0]["client_ip"] # 服务端补了 IP + + +def test_endpoint_rejects_batch_over_max(client, client_log_file): + body = {"device_id": "d-1", + "logs": [{"client_ts": i, "level": "info", "msg": str(i)} for i in range(501)]} + resp = _post(client, body) + assert resp.status_code == 422 # Pydantic max_length=500 + + +def test_endpoint_rejects_body_over_limit(client, client_log_file, monkeypatch): + monkeypatch.setenv("APPLOG_MAX_BODY_BYTES", "50") + body = {"device_id": "d-1", "logs": [{"client_ts": 1, "level": "info", "msg": "x" * 500}]} + resp = _post(client, body) + assert resp.status_code == 413 # 依赖查 Content-Length,body 校验前拦截 + + +def test_endpoint_returns_200_on_write_failure(client, client_log_file, monkeypatch): + class _BoomLogger: + name = "client" + propagate = False + handlers: list = [] + + def info(self, *a, **k): + raise RuntimeError("disk full") + + monkeypatch.setattr(client_log, "get_logger", lambda: _BoomLogger()) + body = {"device_id": "d-1", "logs": [ + {"client_ts": 1, "level": "info", "msg": "a"}, + {"client_ts": 2, "level": "info", "msg": "b"}]} + resp = _post(client, body) + assert resp.status_code == 200 # fire-and-forget:写失败不 500 + assert resp.json() == {"ok": True, "received": 0, "dropped": 2} + + +def test_endpoint_missing_device_id_is_422(client, client_log_file): + resp = _post(client, {"logs": [{"client_ts": 1, "level": "info", "msg": "a"}]}) + assert resp.status_code == 422 # device_id 必填 +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pytest tests/test_applog.py -q` +Expected: FAIL —— 端点未注册,`POST /api/v1/applog/batch` 返回 404(happy-path 断言 200 失败)。 + +- [ ] **Step 3: 实现 schema `app/schemas/applog.py`** + +```python +"""客户端运行日志批量上报 schema。 + +批级公共字段(device_id/user_id/app_ver/platform/sent_at)发一次;logs 为原始 dict 列表, +**不强类型**——尽力而为的日志链路,单条内容异常不该让整批 422。每条的「白名单键 + data +兜底」拆分在写入层 [app.core.client_log] 做(见 spec §4/§5)。 +""" +from __future__ import annotations + +import os +from typing import Any + +from pydantic import BaseModel, Field + +_MAX_BATCH = int(os.getenv("APPLOG_MAX_BATCH", "500")) + + +class AppLogBatchIn(BaseModel): + device_id: str = Field(max_length=64) + user_id: int | None = None + app_ver: str | None = Field(default=None, max_length=32) + platform: str | None = Field(default=None, max_length=16) + sent_at: int | None = None + logs: list[dict[str, Any]] = Field(min_length=1, max_length=_MAX_BATCH) + + +class AppLogIngestOut(BaseModel): + ok: bool = True + received: int + dropped: int = 0 +``` + +- [ ] **Step 4: 实现端点 `app/api/v1/applog.py`** + +```python +"""客户端运行日志批量上报接口。 + +POST /api/v1/applog/batch — 批量接收客户端运行日志,逐条写专用滚动文件 logs/app-client.log +(供 Logtail 采进独立 SLS logstore)。鉴权同 analytics(不强制登录,user_id 可选在 body)。 +fire-and-forget:写失败也不 500(避免客户端重试风暴);超批 422、超体积 413、msg 超限截断。 +""" +from __future__ import annotations + +import os + +from fastapi import APIRouter, Depends, HTTPException, Request + +from app.core.client_log import write_records +from app.core.ratelimit import rate_limit +from app.schemas.applog import AppLogBatchIn, AppLogIngestOut + +router = APIRouter(prefix="/api/v1/applog", tags=["applog"]) + + +def _client_ip(request: Request) -> str: + """取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP(同 analytics)。""" + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "" + + +def _enforce_body_limit(request: Request) -> None: + """依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。""" + max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024))) + cl = request.headers.get("content-length") + if cl is not None and cl.isdigit() and int(cl) > max_bytes: + raise HTTPException(status_code=413, detail="日志批量过大") + + +@router.post( + "/batch", + response_model=AppLogIngestOut, + summary="批量上报客户端运行日志", + dependencies=[ + Depends(rate_limit(120, 60, "applog-batch")), + Depends(_enforce_body_limit), + ], +) +def ingest_logs(batch: AppLogBatchIn, request: Request) -> AppLogIngestOut: + received, dropped = write_records( + batch.logs, + meta={ + "device_id": batch.device_id, + "user_id": batch.user_id, + "app_ver": batch.app_ver, + "platform": batch.platform, + "sent_at": batch.sent_at, + }, + client_ip=_client_ip(request), + ) + return AppLogIngestOut(received=received, dropped=dropped) +``` + +- [ ] **Step 5: 注册路由 `app/main.py`** + +在 import 区(analytics_router 之后,约 [app/main.py:23](../../../app/main.py#L23))加: + +```python +from app.api.v1.applog import router as applog_router +``` + +在 `include_router` 区(`app.include_router(analytics_router)` 之后,约 [app/main.py:125](../../../app/main.py#L125))加: + +```python +app.include_router(applog_router) +``` + +- [ ] **Step 6: 跑测试确认通过** + +Run: `pytest tests/test_applog.py -q` +Expected: PASS(writer 5 + 端点 5,共 10 个)。 + +- [ ] **Step 7: 全量测试 + ruff** + +Run: `pytest -q && ruff check app/api/v1/applog.py app/schemas/applog.py app/main.py` +Expected: 全绿、无 lint 错误。 + +- [ ] **Step 8: 提交** + +```bash +git add app/api/v1/applog.py app/schemas/applog.py app/main.py tests/test_applog.py +git commit -m "feat(applog): POST /api/v1/applog/batch 批量上报端点(限流+体积上限+fire-and-forget)" +``` + +--- + +## Task 3: `.env.example` 文档化新 env(可选但推荐) + +**Files:** +- Modify: `.env.example` + +- [ ] **Step 1: 追加 env 说明** + +在 `.env.example` 末尾(或日志相关区块)追加,让运维知道可调项: + +```bash +# 客户端运行日志上报(POST /api/v1/applog/batch)→ 落 logs/app-client.log 供 Logtail 采集 +# 独立于服务日志 app-server.log;滚动机制同服务日志。默认值见 app/core/client_log.py。 +# CLIENT_LOG_FILE=logs/app-client.log +# CLIENT_LOG_MAX_BYTES=20971520 # 单文件 20MB 滚动 +# CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲 +# CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段 +# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422) +# APPLOG_MAX_BODY_BYTES=1048576 # 请求体上限 1MB(超 → 413) +# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超限截断 +``` + +- [ ] **Step 2: 提交** + +```bash +git add .env.example +git commit -m "docs(applog): .env.example 补充客户端日志上报可调 env" +``` + +> 若仓库无 `.env.example`(以 `git ls-files .env.example` 确认),跳过本任务。 + +--- + +## Definition of Done + +- [ ] `pytest -q` 全绿(含新增 `tests/test_applog.py` 10 用例)。 +- [ ] `ruff check .` 无错误。 +- [ ] `POST /api/v1/applog/batch` 手动冒烟:发一批含 `trace_id` 的日志,确认 `logs/app-client.log` 出现对应单行 JSON、`trace_id` 在顶层、未知键落在 `data`,且 `logs/app-server.log` **未**被写入客户端记录。 +- [ ] Spec 的运维项(独立 logstore、Logtail JSON 模式采 `app-client.log`、给 `trace_id` 建索引、nginx `client_max_body_size` 对齐)已同步给运维(不在本仓代码内,见 spec §8)。 + +## Self-Review 结论(作者已核对) + +- **Spec 覆盖**:§4 端点/限额 → Task 2;§5 白名单+data 兜底 → Task 1 `_build_line`;§6 落盘/滚动/单 worker 注释 → Task 1;§7 trace_id 顶层 → Task 1 + 端点测试;§9 fire-and-forget → Task 1 `write_records` + 端点测试;§13 env → Task 1/2 读取 + Task 3 文档化。§8 为纯运维配置,列入 Definition of Done。 +- **无占位符**:所有步骤含完整代码/命令/预期。 +- **类型/命名一致**:`write_records(records, *, meta, client_ip) -> (received, dropped)`、`get_logger()`、`reset_client_logger()`、`_TOP_LEVEL_KEYS`、`AppLogBatchIn/AppLogIngestOut` 在 Task 1/2 间一致引用。 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 index a8a2cf0..479d27f 100644 --- a/docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md +++ b/docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md @@ -68,7 +68,7 @@ Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网 | 限额 | 默认 | 超限行为 | env | |---|---|---|---| | 单批条数 | 500 | 422(Pydantic) | `APPLOG_MAX_BATCH` | -| body 字节 | 1 MB | 413(handler 查 `Content-Length`;缺该头时按已读字节数兜底截断) | `APPLOG_MAX_BODY_BYTES` | +| body 字节 | 1 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` 关闭。 @@ -89,7 +89,7 @@ Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网 **为什么这么设计(防 SLS 索引列爆炸):** Logtail JSON 模式下每个**顶层 key** 都会成为 logstore 一个可索引字段/列。若放任客户端往顶层写任意 key(甚至把动态 id 拼进 key),logstore 会长出成千上万个不同顶层列 → 索引成本膨胀、可能撞字段数上限、schema 混乱到无法建稳定 dashboard/告警,一个客户端 bug 就能把 logstore 搞脏。 -**「兜底」= 服务端强制、不信任客户端。** Pydantic 模型对每条 `dict` 用 `extra='allow'` + 一个校验器:把**白名单外的顶层键统一挪进 `data`**(而非透传到顶层,也非静默丢弃——日志要保真)。于是**输出行顶层列恒定**,无论客户端怎么发。 +**「兜底」= 服务端强制、不信任客户端。** `logs` 以 `list[dict]` 原样收下(不强类型,单条异常不该让整批 422),在**写入层 `client_log.py`** 逐条拆分:白名单键提顶层,**其余键(含客户端自带的 `data`)统一并进 `data`**(而非透传到顶层,也非静默丢弃——日志要保真)。于是**输出行顶层列恒定**,无论客户端怎么发。 这与现有 `JsonFormatter` 平铺 `phase/step/command/cost_ms` 同思路,区别:那些 extra 是**服务端可信有限**的键;客户端不可信无界,故只给一个 `data` 沙盒。 @@ -160,9 +160,9 @@ Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网 | 文件 | 职责 | |---|---| -| `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/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`) -- 2.52.0 From bf4b08d567df363f5fbf731588f5bd99045772b0 Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 01:03:55 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat(applog):=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=97=A5=E5=BF=97=E4=B8=93=E7=94=A8=E8=90=BD=E7=9B=98?= =?UTF-8?q?=20writer(=E7=99=BD=E5=90=8D=E5=8D=95+data=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?,=20propagate=3DFalse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/client_log.py | 145 +++++++++++++++++++++++++++++++++++++++++ tests/test_applog.py | 95 +++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 app/core/client_log.py create mode 100644 tests/test_applog.py diff --git a/app/core/client_log.py b/app/core/client_log.py new file mode 100644 index 0000000..73c6ae3 --- /dev/null +++ b/app/core/client_log.py @@ -0,0 +1,145 @@ +"""客户端运行日志专用落盘 writer(独立于服务端 app-server.log)。 + +- 独占 logger "client" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。 +- 每条按「白名单键(client_ts/level/trace_id/tag/msg)提顶层 + 其余并入 data」封装,再 + json.dumps 成一行写出(钉死 SLS 索引列;见 spec §5)。formatter 用 %(message)s——行本身 + 已是 JSON,不能再过 JsonFormatter 二次编码。 +- 滚动 20MB×10(env 可调),与服务日志同机制。 + ⚠️ 依赖 --workers 1:RotatingFileHandler 多进程并发 doRollover 会损坏/丢日志;扩 worker + 前换 QueueHandler→单写入者 / 外部 logrotate(copytruncate) / 写 stdout 交 journald。 + +服务端补的字段(time/source/service/client_ip/device_id/...)是「事实」,与客户端自述分开。 +`time` 用服务端接收时间作 SLS 主时间(客户端时钟不可信),client_ts 另存为可查字段。 +""" +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime +from logging.handlers import RotatingFileHandler +from pathlib import Path + +# 仅这些客户端键提到输出行顶层;其余(含客户端自带 data)一律并入 data,防 SLS 索引列爆炸 +_TOP_LEVEL_KEYS = ("client_ts", "level", "trace_id", "tag", "msg") + +_logger: logging.Logger | None = None + + +def _max_msg_bytes() -> int: + return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192")) + + +def _build_logger() -> logging.Logger: + lg = logging.getLogger("client") + lg.setLevel(logging.INFO) + lg.propagate = False # 不冒泡到 root → 不写进 app-server.log + log_file = os.getenv("CLIENT_LOG_FILE") or str( + Path(os.getenv("LOG_DIR", "logs")) / "app-client.log" + ) + Path(log_file).parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + log_file, + maxBytes=int(os.getenv("CLIENT_LOG_MAX_BYTES", str(20 * 1024 * 1024))), + backupCount=int(os.getenv("CLIENT_LOG_BACKUP_COUNT", "10")), + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) # 行已是 JSON,不再包装 + lg.handlers = [handler] + return lg + + +def get_logger() -> logging.Logger: + global _logger + if _logger is None: + _logger = _build_logger() + return _logger + + +def reset_client_logger() -> None: + """测试用:关闭并丢弃当前 logger,使下次 get_logger 按当时 env 重建(切临时文件)。""" + global _logger + if _logger is not None: + for h in list(_logger.handlers): + h.close() + _logger.handlers = [] + _logger = None + + +def _truncate_msg(msg: str) -> tuple[str, bool]: + raw = msg.encode("utf-8") + limit = _max_msg_bytes() + if len(raw) <= limit: + return msg, False + # 按字节截断后解码,忽略截断处半个多字节字符 + return raw[:limit].decode("utf-8", "ignore") + "…[truncated]", True + + +def _build_line( + record: dict, *, meta: dict, client_ip: str, service: str, now_iso: str +) -> str: + out: dict = { + "time": now_iso, + "source": "client", + "service": service, + "client_ip": client_ip, + } + # 批级公共字段(非空才带) + for k in ("device_id", "user_id", "app_ver", "platform", "sent_at"): + v = meta.get(k) + if v is not None: + out[k] = v + # 白名单键提顶层 + if record.get("level") is not None: + out["level"] = str(record["level"]).upper() + if record.get("trace_id"): + out["trace_id"] = record["trace_id"] + if record.get("tag"): + out["tag"] = record["tag"] + if record.get("client_ts") is not None: + out["client_ts"] = record["client_ts"] + if record.get("msg") is not None: + msg, truncated = _truncate_msg(str(record["msg"])) + out["msg"] = msg + if truncated: + out["msg_truncated"] = True + # 其余键(含客户端自带 data)并入 data + data: dict = {} + client_data = record.get("data") + if isinstance(client_data, dict): + data.update(client_data) + for k, v in record.items(): + if k in _TOP_LEVEL_KEYS or k == "data": + continue + data[k] = v + if data: + out["data"] = data + return json.dumps(out, ensure_ascii=False, default=str) + + +def write_records( + records: list[dict], *, meta: dict, client_ip: str +) -> tuple[int, int]: + """把一批客户端日志逐条写入专用文件。返回 (received, dropped)。 + + 尽力而为(fire-and-forget):logger 初始化或单条写入失败只跳过并计 dropped, + 不抛给上层——端点因此永不因写日志而 500。 + """ + try: + lg = get_logger() + except Exception: # noqa: BLE001 — 初始化失败也不能让端点 500 + logging.getLogger("shagua.applog").exception("client log writer init failed") + return 0, len(records) + service = os.getenv("CLIENT_LOG_SERVICE_NAME", "app-client") + now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + received = dropped = 0 + for rec in records: + try: + line = _build_line( + rec, meta=meta, client_ip=client_ip, service=service, now_iso=now_iso + ) + lg.info(line) + received += 1 + except Exception: # noqa: BLE001 — 坏条跳过,不影响其余 + dropped += 1 + return received, dropped diff --git a/tests/test_applog.py b/tests/test_applog.py new file mode 100644 index 0000000..aef2352 --- /dev/null +++ b/tests/test_applog.py @@ -0,0 +1,95 @@ +"""客户端运行日志上报:writer 单测 + 端点集成测试。""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from app.core import client_log + + +@pytest.fixture() +def client_log_file(tmp_path, monkeypatch): + """把客户端日志切到临时文件,并重置 writer 单例使其按当时 env 重建。""" + p = tmp_path / "app-client.log" + monkeypatch.setenv("CLIENT_LOG_FILE", str(p)) + client_log.reset_client_logger() + yield p + client_log.reset_client_logger() + + +def _read_lines(p: Path) -> list[dict]: + text = p.read_text(encoding="utf-8").strip() + return [json.loads(ln) for ln in text.splitlines() if ln] + + +# ---------------- writer 层 ---------------- + +def _meta(**kw) -> dict: + base = {"device_id": "d-1", "user_id": None, "app_ver": None, + "platform": None, "sent_at": None} + base.update(kw) + return base + + +def test_writer_writes_one_line_per_record(client_log_file): + recs = [ + {"client_ts": 1737000000000, "level": "info", "msg": "hello"}, + {"client_ts": 1737000000001, "level": "error", "msg": "boom", "tag": "net"}, + ] + received, dropped = client_log.write_records( + recs, meta=_meta(user_id=42, app_ver="1.2.3", platform="android"), + client_ip="1.2.3.4", + ) + assert (received, dropped) == (2, 0) + lines = _read_lines(client_log_file) + assert len(lines) == 2 + assert lines[0]["source"] == "client" + assert lines[0]["service"] == "app-client" + assert lines[0]["device_id"] == "d-1" + assert lines[0]["user_id"] == 42 + assert lines[0]["app_ver"] == "1.2.3" + assert lines[0]["client_ip"] == "1.2.3.4" + assert lines[0]["level"] == "INFO" # 归一化大写 + assert lines[0]["msg"] == "hello" + assert lines[0]["client_ts"] == 1737000000000 + assert lines[1]["tag"] == "net" + + +def test_writer_hoists_trace_id_to_top_level(client_log_file): + client_log.write_records( + [{"client_ts": 1, "level": "info", "msg": "x", "trace_id": "abc123"}], + meta=_meta(), client_ip="", + ) + assert _read_lines(client_log_file)[0]["trace_id"] == "abc123" + + +def test_writer_sweeps_unknown_keys_into_data(client_log_file): + client_log.write_records( + [{"client_ts": 1, "level": "info", "msg": "x", + "foo": 123, "data": {"bar": "baz"}}], + meta=_meta(), client_ip="", + ) + line = _read_lines(client_log_file)[0] + assert "foo" not in line # 白名单外不进顶层 + assert line["data"]["foo"] == 123 # 兜底进 data + assert line["data"]["bar"] == "baz" # 客户端自带 data 合并进来 + + +def test_writer_truncates_oversize_msg(client_log_file, monkeypatch): + monkeypatch.setenv("APPLOG_MAX_MSG_BYTES", "10") + received, dropped = client_log.write_records( + [{"client_ts": 1, "level": "info", "msg": "x" * 100}], + meta=_meta(), client_ip="", + ) + assert (received, dropped) == (1, 0) # 截断而非丢弃 + line = _read_lines(client_log_file)[0] + assert line["msg_truncated"] is True + assert line["msg"].endswith("…[truncated]") + + +def test_writer_logger_does_not_propagate(client_log_file): + lg = client_log.get_logger() + assert lg.name == "client" + assert lg.propagate is False # 不冒泡到 root → 不写 app-server.log -- 2.52.0 From ab46dec102ece1b88541fafc487826535790a89c Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 01:23:24 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(applog):=20=E8=90=BD=E7=9B=98=20write?= =?UTF-8?q?r=20=E8=AF=84=E5=AE=A1=E6=95=B4=E6=94=B9(drop=20=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=20/=20=E6=98=BE=E5=BC=8F=20data=20=E4=BC=98?= =?UTF-8?q?=E5=85=88=20/=20logger=20=E5=91=BD=E5=90=8D=E5=BD=92=E4=B8=80?= =?UTF-8?q?=20/=20=E8=A1=A5=E6=B5=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/client_log.py | 16 +++++++++++----- tests/test_applog.py | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/core/client_log.py b/app/core/client_log.py index 73c6ae3..6cffa86 100644 --- a/app/core/client_log.py +++ b/app/core/client_log.py @@ -27,11 +27,12 @@ _logger: logging.Logger | None = None def _max_msg_bytes() -> int: + # 每次调用现读 env(不设模块级常量):便于运行期调整 / 测试 monkeypatch,开销可忽略。 return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192")) def _build_logger() -> logging.Logger: - lg = logging.getLogger("client") + lg = logging.getLogger("shagua.client_log") # 与仓库 shagua.* 业务 logger 命名一致 lg.setLevel(logging.INFO) lg.propagate = False # 不冒泡到 root → 不写进 app-server.log log_file = os.getenv("CLIENT_LOG_FILE") or str( @@ -103,15 +104,16 @@ def _build_line( out["msg"] = msg if truncated: out["msg_truncated"] = True - # 其余键(含客户端自带 data)并入 data + # 其余键并入 data:先收白名单外的散键(兜底),再让客户端显式的 data 覆盖同名散键 + # —— 显式 data 为准,不静默丢客户端明确给的值(保真)。 data: dict = {} - client_data = record.get("data") - if isinstance(client_data, dict): - data.update(client_data) for k, v in record.items(): if k in _TOP_LEVEL_KEYS or k == "data": continue data[k] = v + client_data = record.get("data") + if isinstance(client_data, dict): + data.update(client_data) if data: out["data"] = data return json.dumps(out, ensure_ascii=False, default=str) @@ -131,6 +133,7 @@ def write_records( logging.getLogger("shagua.applog").exception("client log writer init failed") return 0, len(records) service = os.getenv("CLIENT_LOG_SERVICE_NAME", "app-client") + # 一批共用同一「服务端接收时间」:降开销,且语义上是服务端「收到」而非逐条「处理」时间。 now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] received = dropped = 0 for rec in records: @@ -141,5 +144,8 @@ def write_records( lg.info(line) received += 1 except Exception: # noqa: BLE001 — 坏条跳过,不影响其余 + logging.getLogger("shagua.applog").exception( + "client log record dropped client_ip=%s", client_ip + ) dropped += 1 return received, dropped diff --git a/tests/test_applog.py b/tests/test_applog.py index aef2352..518bc18 100644 --- a/tests/test_applog.py +++ b/tests/test_applog.py @@ -87,9 +87,22 @@ def test_writer_truncates_oversize_msg(client_log_file, monkeypatch): line = _read_lines(client_log_file)[0] assert line["msg_truncated"] is True assert line["msg"].endswith("…[truncated]") + body = line["msg"].removesuffix("…[truncated]") + assert len(body.encode("utf-8")) <= 10 # 截断体不超过字节上限 def test_writer_logger_does_not_propagate(client_log_file): lg = client_log.get_logger() - assert lg.name == "client" + assert lg.name == "shagua.client_log" assert lg.propagate is False # 不冒泡到 root → 不写 app-server.log + + +def test_writer_drops_bad_record_without_raising(client_log_file, monkeypatch): + def _boom(*a, **kw): + raise RuntimeError("boom") + monkeypatch.setattr(client_log, "_build_line", _boom) + received, dropped = client_log.write_records( + [{"client_ts": 1, "level": "info", "msg": "x"}], + meta=_meta(), client_ip="", + ) + assert (received, dropped) == (0, 1) # 坏条计 dropped,且不抛给上层 -- 2.52.0 From 267d65a47321fb6f244d61187229c7d12a02fede Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 01:24:28 +0800 Subject: [PATCH 05/12] =?UTF-8?q?docs(applog):=20=E4=BF=AE=E6=AD=A3=20writ?= =?UTF-8?q?er=20docstring=20=E9=87=8C=E8=BF=87=E6=9C=9F=E7=9A=84=20logger?= =?UTF-8?q?=20=E5=90=8D(client=E2=86=92shagua.client=5Flog)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/client_log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/client_log.py b/app/core/client_log.py index 6cffa86..c2785e9 100644 --- a/app/core/client_log.py +++ b/app/core/client_log.py @@ -1,6 +1,6 @@ """客户端运行日志专用落盘 writer(独立于服务端 app-server.log)。 -- 独占 logger "client" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。 +- 独占 logger "shagua.client_log" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。 - 每条按「白名单键(client_ts/level/trace_id/tag/msg)提顶层 + 其余并入 data」封装,再 json.dumps 成一行写出(钉死 SLS 索引列;见 spec §5)。formatter 用 %(message)s——行本身 已是 JSON,不能再过 JsonFormatter 二次编码。 -- 2.52.0 From 2de1f537c051e2acb3ec7cadad42d916d0ba6c8e Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 01:31:29 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat(applog):=20POST=20/api/v1/applog/bat?= =?UTF-8?q?ch=20=E6=89=B9=E9=87=8F=E4=B8=8A=E6=8A=A5=E7=AB=AF=E7=82=B9(?= =?UTF-8?q?=E9=99=90=E6=B5=81+=E4=BD=93=E7=A7=AF=E4=B8=8A=E9=99=90+fire-an?= =?UTF-8?q?d-forget)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/applog.py | 57 ++++++++++++++++++++++++++++++++++++++++ app/main.py | 2 ++ app/schemas/applog.py | 29 +++++++++++++++++++++ tests/test_applog.py | 60 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 app/api/v1/applog.py create mode 100644 app/schemas/applog.py diff --git a/app/api/v1/applog.py b/app/api/v1/applog.py new file mode 100644 index 0000000..7310bad --- /dev/null +++ b/app/api/v1/applog.py @@ -0,0 +1,57 @@ +"""客户端运行日志批量上报接口。 + +POST /api/v1/applog/batch — 批量接收客户端运行日志,逐条写专用滚动文件 logs/app-client.log +(供 Logtail 采进独立 SLS logstore)。鉴权同 analytics(不强制登录,user_id 可选在 body)。 +fire-and-forget:写失败也不 500(避免客户端重试风暴);超批 422、超体积 413、msg 超限截断。 +""" +from __future__ import annotations + +import os + +from fastapi import APIRouter, Depends, HTTPException, Request + +from app.core.client_log import write_records +from app.core.ratelimit import rate_limit +from app.schemas.applog import AppLogBatchIn, AppLogIngestOut + +router = APIRouter(prefix="/api/v1/applog", tags=["applog"]) + + +def _client_ip(request: Request) -> str: + """取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP(同 analytics)。""" + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "" + + +def _enforce_body_limit(request: Request) -> None: + """依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。""" + max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024))) + cl = request.headers.get("content-length") + if cl is not None and cl.isdigit() and int(cl) > max_bytes: + raise HTTPException(status_code=413, detail="日志批量过大") + + +@router.post( + "/batch", + response_model=AppLogIngestOut, + summary="批量上报客户端运行日志", + dependencies=[ + Depends(rate_limit(120, 60, "applog-batch")), + Depends(_enforce_body_limit), + ], +) +def ingest_logs(batch: AppLogBatchIn, request: Request) -> AppLogIngestOut: + received, dropped = write_records( + batch.logs, + meta={ + "device_id": batch.device_id, + "user_id": batch.user_id, + "app_ver": batch.app_ver, + "platform": batch.platform, + "sent_at": batch.sent_at, + }, + client_ip=_client_ip(request), + ) + return AppLogIngestOut(received=received, dropped=dropped) diff --git a/app/main.py b/app/main.py index f99611e..d755014 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,7 @@ from app.api.internal.price import router as internal_price_router from app.api.internal.store import router as internal_store_router from app.api.v1.ad import router as ad_router from app.api.v1.analytics import router as analytics_router +from app.api.v1.applog import router as applog_router from app.api.v1.auth import router as auth_router from app.api.v1.compare import router as compare_router from app.api.v1.compare_milestone import router as compare_milestone_router @@ -123,6 +124,7 @@ app.include_router(auth_router) app.include_router(user_router) app.include_router(feedback_router) app.include_router(analytics_router) +app.include_router(applog_router) app.include_router(invite_router) app.include_router(coupon_router) app.include_router(device_router) diff --git a/app/schemas/applog.py b/app/schemas/applog.py new file mode 100644 index 0000000..0ee26a1 --- /dev/null +++ b/app/schemas/applog.py @@ -0,0 +1,29 @@ +"""客户端运行日志批量上报 schema。 + +批级公共字段(device_id/user_id/app_ver/platform/sent_at)发一次;logs 为原始 dict 列表, +**不强类型**——尽力而为的日志链路,单条内容异常不该让整批 422。每条的「白名单键 + data +兜底」拆分在写入层 [app.core.client_log] 做(见 spec §4/§5)。 +""" +from __future__ import annotations + +import os +from typing import Any + +from pydantic import BaseModel, Field + +_MAX_BATCH = int(os.getenv("APPLOG_MAX_BATCH", "500")) + + +class AppLogBatchIn(BaseModel): + device_id: str = Field(max_length=64) + user_id: int | None = None + app_ver: str | None = Field(default=None, max_length=32) + platform: str | None = Field(default=None, max_length=16) + sent_at: int | None = None + logs: list[dict[str, Any]] = Field(min_length=1, max_length=_MAX_BATCH) + + +class AppLogIngestOut(BaseModel): + ok: bool = True + received: int + dropped: int = 0 diff --git a/tests/test_applog.py b/tests/test_applog.py index 518bc18..fa8b3ce 100644 --- a/tests/test_applog.py +++ b/tests/test_applog.py @@ -106,3 +106,63 @@ def test_writer_drops_bad_record_without_raising(client_log_file, monkeypatch): meta=_meta(), client_ip="", ) assert (received, dropped) == (0, 1) # 坏条计 dropped,且不抛给上层 + + +# ---------------- 端点层 ---------------- + +def _post(client, body): + return client.post("/api/v1/applog/batch", json=body) + + +def test_endpoint_happy_path(client, client_log_file): + body = { + "device_id": "d-1", "user_id": 42, "app_ver": "1.2.3", "platform": "android", + "logs": [ + {"client_ts": 1, "level": "info", "msg": "a", "trace_id": "t1"}, + {"client_ts": 2, "level": "warn", "msg": "b"}, + ], + } + resp = _post(client, body) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "received": 2, "dropped": 0} + lines = _read_lines(client_log_file) + assert len(lines) == 2 + assert lines[0]["trace_id"] == "t1" # 端到端:trace_id 落到文件顶层 + assert lines[0]["client_ip"] # 服务端补了 IP + + +def test_endpoint_rejects_batch_over_max(client, client_log_file): + body = {"device_id": "d-1", + "logs": [{"client_ts": i, "level": "info", "msg": str(i)} for i in range(501)]} + resp = _post(client, body) + assert resp.status_code == 422 # Pydantic max_length=500 + + +def test_endpoint_rejects_body_over_limit(client, client_log_file, monkeypatch): + monkeypatch.setenv("APPLOG_MAX_BODY_BYTES", "50") + body = {"device_id": "d-1", "logs": [{"client_ts": 1, "level": "info", "msg": "x" * 500}]} + resp = _post(client, body) + assert resp.status_code == 413 # 依赖查 Content-Length,body 校验前拦截 + + +def test_endpoint_returns_200_on_write_failure(client, client_log_file, monkeypatch): + class _BoomLogger: + name = "shagua.client_log" + propagate = False + handlers: list = [] + + def info(self, *a, **k): + raise RuntimeError("disk full") + + monkeypatch.setattr(client_log, "get_logger", lambda: _BoomLogger()) + body = {"device_id": "d-1", "logs": [ + {"client_ts": 1, "level": "info", "msg": "a"}, + {"client_ts": 2, "level": "info", "msg": "b"}]} + resp = _post(client, body) + assert resp.status_code == 200 # fire-and-forget:写失败不 500 + assert resp.json() == {"ok": True, "received": 0, "dropped": 2} + + +def test_endpoint_missing_device_id_is_422(client, client_log_file): + resp = _post(client, {"logs": [{"client_ts": 1, "level": "info", "msg": "a"}]}) + assert resp.status_code == 422 # device_id 必填 -- 2.52.0 From 74da3525a9d960b87369303b9dab927d657be978 Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 01:44:59 +0800 Subject: [PATCH 07/12] =?UTF-8?q?refactor(api):=20=E6=8F=90=E5=8F=96=20get?= =?UTF-8?q?=5Fclient=5Fip=20=E5=88=B0=20deps(applog/analytics=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8)=20+=20applog=20=E4=B8=8A=E9=99=90=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审整改:消除 _client_ip 第三份副本(改到 deps.get_client_ip,路由层共用); 标注 APPLOG_MAX_BATCH 为导入期常量;body-limit 测试补 nginx 兜底说明。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/deps.py | 14 +++++++++++++- app/api/v1/analytics.py | 12 ++---------- app/api/v1/applog.py | 11 ++--------- app/schemas/applog.py | 2 ++ tests/test_applog.py | 2 ++ 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/app/api/deps.py b/app/api/deps.py index 0b9db63..2c13471 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Annotated -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session @@ -77,6 +77,18 @@ def get_current_user_optional( return user +def get_client_ip(request: Request) -> str: + """客户端真实 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP。 + + 路由层取 IP 的规范实现(analytics / applog 等共用);core 层(ratelimit)因不能 + 反向依赖 app.api,自留一份私有副本。 + """ + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "" + + CurrentUser = Annotated[User, Depends(get_current_user)] OptionalUser = Annotated[User | None, Depends(get_current_user_optional)] DbSession = Annotated[Session, Depends(get_db)] diff --git a/app/api/v1/analytics.py b/app/api/v1/analytics.py index 23d2f82..3f36f1b 100644 --- a/app/api/v1/analytics.py +++ b/app/api/v1/analytics.py @@ -10,7 +10,7 @@ import logging from fastapi import APIRouter, HTTPException, Request -from app.api.deps import DbSession +from app.api.deps import DbSession, get_client_ip from app.repositories import analytics as analytics_repo from app.repositories import analytics_selfstat as selfstat_repo from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut @@ -20,19 +20,11 @@ router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"]) logger = logging.getLogger("shagua.analytics") -def _client_ip(request: Request) -> str: - """取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。""" - xff = request.headers.get("x-forwarded-for") - if xff: - return xff.split(",")[0].strip() - return request.client.host if request.client else "" - - @router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件") def ingest_events( batch: AnalyticsBatchIn, request: Request, db: DbSession ) -> AnalyticsIngestOut: - n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request)) + n = analytics_repo.record_batch(db, batch, client_ip=get_client_ip(request)) return AnalyticsIngestOut(received=n) diff --git a/app/api/v1/applog.py b/app/api/v1/applog.py index 7310bad..787833a 100644 --- a/app/api/v1/applog.py +++ b/app/api/v1/applog.py @@ -10,6 +10,7 @@ import os from fastapi import APIRouter, Depends, HTTPException, Request +from app.api.deps import get_client_ip from app.core.client_log import write_records from app.core.ratelimit import rate_limit from app.schemas.applog import AppLogBatchIn, AppLogIngestOut @@ -17,14 +18,6 @@ from app.schemas.applog import AppLogBatchIn, AppLogIngestOut router = APIRouter(prefix="/api/v1/applog", tags=["applog"]) -def _client_ip(request: Request) -> str: - """取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP(同 analytics)。""" - xff = request.headers.get("x-forwarded-for") - if xff: - return xff.split(",")[0].strip() - return request.client.host if request.client else "" - - def _enforce_body_limit(request: Request) -> None: """依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。""" max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024))) @@ -52,6 +45,6 @@ def ingest_logs(batch: AppLogBatchIn, request: Request) -> AppLogIngestOut: "platform": batch.platform, "sent_at": batch.sent_at, }, - client_ip=_client_ip(request), + client_ip=get_client_ip(request), ) return AppLogIngestOut(received=received, dropped=dropped) diff --git a/app/schemas/applog.py b/app/schemas/applog.py index 0ee26a1..9bbb9d4 100644 --- a/app/schemas/applog.py +++ b/app/schemas/applog.py @@ -11,6 +11,8 @@ from typing import Any from pydantic import BaseModel, Field +# 单批条数上限。注意:这是**导入期**常量(Pydantic Field(max_length=) 在类定义时求值), +# 改它需重启进程;要运行期可调的上限用 APPLOG_MAX_BODY_BYTES(端点里 call-time 读)。 _MAX_BATCH = int(os.getenv("APPLOG_MAX_BATCH", "500")) diff --git a/tests/test_applog.py b/tests/test_applog.py index fa8b3ce..15923a4 100644 --- a/tests/test_applog.py +++ b/tests/test_applog.py @@ -139,6 +139,8 @@ def test_endpoint_rejects_batch_over_max(client, client_log_file): def test_endpoint_rejects_body_over_limit(client, client_log_file, monkeypatch): + # 只测有 Content-Length 的常规情形(TestClient/requests 恒发该头)。缺该头(chunked) + # 时依赖 nginx client_max_body_size 兜底,不在应用层测。 monkeypatch.setenv("APPLOG_MAX_BODY_BYTES", "50") body = {"device_id": "d-1", "logs": [{"client_ts": 1, "level": "info", "msg": "x" * 500}]} resp = _post(client, body) -- 2.52.0 From 5881dd4f053c631cc079fc4eada0bd5a1c47f988 Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 01:47:06 +0800 Subject: [PATCH 08/12] =?UTF-8?q?docs(applog):=20.env.example=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=97=A5=E5=BF=97=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E5=8F=AF=E8=B0=83=20env(CLIENT=5FLOG=5F*/APPLOG=5F*)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.env.example b/.env.example index 110e939..df4708f 100644 --- a/.env.example +++ b/.env.example @@ -137,3 +137,15 @@ PANGLE_REPORT_SECURITY_KEY= # GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。 PANGLE_REPORT_SITE_ID_PROD=5830519 PANGLE_REPORT_SITE_ID_TEST=5832303 + +# ===== 客户端运行日志上报(POST /api/v1/applog/batch)===== +# 客户端批量上报的 App 运行日志逐条落到独立滚动文件 logs/app-client.log,供阿里云 Logtail +# 采进【独立 SLS logstore】(与服务日志 app-server.log 分开;滚动机制相同,trace_id 可跨层检索)。 +# 全部有默认值,不填即用默认(定义见 app/core/client_log.py 与 app/api/v1/applog.py)。 +# CLIENT_LOG_FILE=logs/app-client.log # 落盘路径 +# CLIENT_LOG_MAX_BYTES=20971520 # 单文件 20MB 滚动 +# CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲(给 Logtail 断线留余量) +# CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段 +# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启) +# APPLOG_MAX_BODY_BYTES=1048576 # 请求体上限 1MB(超 → 413;运行期可调) +# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断 -- 2.52.0 From a604f4d614a8631a11a9dce075d45146b6c7dca5 Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 01:54:38 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(applog):=20=E7=BB=99=20SLS=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E5=AD=97=E6=AE=B5=20trace=5Fid/tag/level=20?= =?UTF-8?q?=E5=8A=A0=E9=95=BF=E5=BA=A6=E4=B8=8A=E9=99=90(=E7=BB=88?= =?UTF-8?q?=E5=AE=A1=20M1=20=E5=8A=A0=E5=9B=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只有 msg 有字节截断,trace_id/tag/level(spec §8 的索引字段)此前不限长, 客户端可塞超大值撑爆 SLS 索引/抬成本。统一转 str 并截断(256/128/16), data 内的值仍不限(留待后续脱敏 knob)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/client_log.py | 14 ++++++++++---- tests/test_applog.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/core/client_log.py b/app/core/client_log.py index c2785e9..be9772a 100644 --- a/app/core/client_log.py +++ b/app/core/client_log.py @@ -23,6 +23,12 @@ from pathlib import Path # 仅这些客户端键提到输出行顶层;其余(含客户端自带 data)一律并入 data,防 SLS 索引列爆炸 _TOP_LEVEL_KEYS = ("client_ts", "level", "trace_id", "tag", "msg") +# trace_id/tag/level 是 SLS 索引字段(spec §8):给长度上限,防客户端塞超大值撑爆索引/抬升成本。 +# (msg 另有字节截断;data 内的值不限,留待后续「服务端脱敏」knob。) +_MAX_LEVEL_LEN = 16 +_MAX_TRACE_ID_LEN = 256 +_MAX_TAG_LEN = 128 + _logger: logging.Logger | None = None @@ -90,13 +96,13 @@ def _build_line( v = meta.get(k) if v is not None: out[k] = v - # 白名单键提顶层 + # 白名单键提顶层(索引字段做长度上限 + 统一转 str,保证 SLS 里类型/大小可控) if record.get("level") is not None: - out["level"] = str(record["level"]).upper() + out["level"] = str(record["level"])[:_MAX_LEVEL_LEN].upper() if record.get("trace_id"): - out["trace_id"] = record["trace_id"] + out["trace_id"] = str(record["trace_id"])[:_MAX_TRACE_ID_LEN] if record.get("tag"): - out["tag"] = record["tag"] + out["tag"] = str(record["tag"])[:_MAX_TAG_LEN] if record.get("client_ts") is not None: out["client_ts"] = record["client_ts"] if record.get("msg") is not None: diff --git a/tests/test_applog.py b/tests/test_applog.py index 15923a4..1d96037 100644 --- a/tests/test_applog.py +++ b/tests/test_applog.py @@ -91,6 +91,18 @@ def test_writer_truncates_oversize_msg(client_log_file, monkeypatch): assert len(body.encode("utf-8")) <= 10 # 截断体不超过字节上限 +def test_writer_caps_indexed_field_lengths(client_log_file): + client_log.write_records( + [{"client_ts": 1, "level": "info" * 20, "msg": "x", + "trace_id": "t" * 1000, "tag": "g" * 1000}], + meta=_meta(), client_ip="", + ) + line = _read_lines(client_log_file)[0] + assert len(line["level"]) <= 16 # 索引字段(spec §8)做长度上限 + assert len(line["trace_id"]) <= 256 + assert len(line["tag"]) <= 128 + + def test_writer_logger_does_not_propagate(client_log_file): lg = client_log.get_logger() assert lg.name == "shagua.client_log" -- 2.52.0 From 266b32dee44d32d7baeb7547153b3b263e8e81ad Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 09:35:22 +0800 Subject: [PATCH 10/12] =?UTF-8?q?docs(api):=20=E8=A1=A5=20POST=20/api/v1/a?= =?UTF-8?q?pplog/batch=20=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3=20+=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E7=99=BB=E8=AE=B0(E3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 docs/api/other/applog-batch.md(仿 analytics-events 格式:入参/出参/错误码/说明), README 索引「埋点&订单上报/客户端日志」组加 E3 行,并把该组鉴权注解补上 applog/batch 免登录。 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/README.md | 3 +- docs/api/other/applog-batch.md | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 docs/api/other/applog-batch.md diff --git a/docs/api/README.md b/docs/api/README.md index 4ade4e6..26e911e 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -106,9 +106,10 @@ | 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) | | 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) | | 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected) | -| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) ||| +| **埋点 & 订单上报 / 客户端日志**(前缀分散;全部 Bearer 除 analytics/events、applog/batch 不强制登录) ||| | E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) | | E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) | +| E3 | `POST /api/v1/applog/batch` | 无 | [详情](./other/applog-batch.md)(批量上报客户端运行日志,逐条落独立文件供 Logtail 采进 SLS,不强制登录,每批≤500条) | | **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) ||| | 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) | | 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) | diff --git a/docs/api/other/applog-batch.md b/docs/api/other/applog-batch.md new file mode 100644 index 0000000..8a0e729 --- /dev/null +++ b/docs/api/other/applog-batch.md @@ -0,0 +1,87 @@ +# POST /api/v1/applog/batch — 批量上报客户端运行日志 + +> 所属:客户端日志组(前缀 `/api/v1/applog`) | 鉴权:无(不强制登录,`user_id` 可选带上) | [← 返回 API 索引](../README.md) + +批量接收客户端 App 运行日志(自动化步骤 / 网络 / 崩溃 / 调试等),**逐条**封装成单行 JSON 写入独立滚动文件 `logs/app-client.log`(与服务日志 `app-server.log` 分开),由阿里云 Logtail 采进**独立 SLS logstore**,**不落库**。服务端补 `client_ip`(X-Forwarded-For)与 `time`(接收时间,SLS 主时间)。`trace_id` 提到输出行顶层,便于在 SLS 里跨「客户端 / 服务端」两个 logstore 按 trace 拼出端到端链路。**fire-and-forget**:写失败也返回 2xx,不 500。 + +## 入参 + +批级公共字段发一次;`logs` 里每条只带日志本身。**每条只有 `client_ts/level/trace_id/tag/msg` 会提到输出顶层,其余自定义字段一律并入输出的 `data`**(防 SLS 索引列爆炸)。 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅(≤64) | 设备 ID(限流 / 分组键) | +| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) | +| `app_ver` | string \| null | ❌(≤32) | App 版本 | +| `platform` | string \| null | ❌(≤16) | 平台(`android` / `ios` / `harmony`) | +| `sent_at` | int \| null | ❌ | 批次发送时间(epoch ms) | +| `logs` | list[object] | ✅(1-500 条) | 日志数组(每条为对象,内部字段**不强类型**) | +| `logs[].client_ts` | int | ❌ | 端侧日志时间(epoch ms) | +| `logs[].level` | string | ❌ | 级别(服务端归一化大写,截断 ≤16) | +| `logs[].trace_id` | string \| null | ❌ | 关联服务端比价链路的 trace(有服务端交互的日志带上,截断 ≤256) | +| `logs[].tag` | string \| null | ❌ | 模块 / 分类(截断 ≤128) | +| `logs[].msg` | string | ❌ | 消息主体(超 8KB **字节**截断,加 `msg_truncated` 标记) | +| `logs[].*` | any | ❌ | 其余任意自定义字段 → 一律并入输出的 `data`(客户端自带的 `data` 对象会被合并进来) | + +Mock 入参: +```json +{ + "device_id": "android_abc123def456", + "user_id": 42, + "app_ver": "0.1.5", + "platform": "android", + "sent_at": 1719993700000, + "logs": [ + { + "client_ts": 1719993600000, + "level": "info", + "tag": "automation", + "msg": "compare flow start", + "trace_id": "t_ab12cd34" + }, + { + "client_ts": 1719993615000, + "level": "error", + "tag": "network", + "msg": "timeout calling /price/step", + "trace_id": "t_ab12cd34", + "http_status": 504, + "retry": 2 + } + ] +} +``` +> 上例第二条的 `http_status` / `retry` 不在白名单 → 会被并入落盘行的 `data`:`{"http_status":504,"retry":2}`。 + +## 出参 + +响应 `200`:`AppLogIngestOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ok` | bool | 固定 `true` | +| `received` | int | 成功写入文件的条数 | +| `dropped` | int | 跳过的条数(服务端处理 / 序列化失败;正常为 `0`) | + +Mock 出参: +```json +{ + "ok": true, + "received": 2, + "dropped": 0 +} +``` + +## 错误码 +- `413` 请求体超过上限(默认 1MB;服务端查 `Content-Length`,在 body 校验前拦截。缺该头时由 nginx `client_max_body_size` 兜底) +- `422` `logs` 为空或超过 500 条 / `device_id` 缺失 / 字段类型不符 +- `429` 触发限流(同 IP 每分钟 > 120 次) + +## 说明 +- **落盘 → SLS**:逐条写独立文件 `logs/app-client.log`(单行 JSON,`propagate=False` 不污染 `app-server.log`),由 Logtail JSON 模式采进**独立 logstore**;不进数据库。落盘行除白名单字段外,服务端另补 `time`(接收时间)、`source="client"`、`service`、`client_ip` 及批级 `device_id/user_id/app_ver/platform/sent_at`。 +- **`trace_id` 跨层检索**:字段名与服务端日志一致。客户端应给**有服务端交互**的日志带上当初 API 返回的 `trace_id`(如 `/api/v1/price/step` 等透传族返回的 trace),即可在 SLS 里 `trace_id: "xxx"` 一查拼出「客户端视角 + 服务端比价链路」;纯客户端日志不带即可。 +- **白名单 + `data` 兜底**:只有 `client_ts/level/trace_id/tag/msg` 上顶层,其余键并入 `data`,把 SLS 索引列钉死在固定集合,防客户端任意 key 撑爆索引。索引字段另有长度上限(`level`≤16 / `trace_id`≤256 / `tag`≤128 / `msg`≤8KB)。 +- **fire-and-forget**:写文件失败也返回 2xx(避免客户端重试风暴);网络重试可能在 SLS 造成重复条目,可接受。 +- **量级建议**:客户端做等级过滤 / 采样,攒到一定量再批量上报;单批 ≤500 条、body ≤1MB。 +- `client_ts` 是端侧时间(客户端时钟不可信),`time` 由服务端补(可靠时间轴)。 +- `user_id` 不靠 JWT:未登录态也要采日志;带上便于按用户排查。 -- 2.52.0 From 6c0fc303e1f504672137814b5da616f5ab45d235 Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 10:01:54 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix(applog):=20=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E4=BD=93=E4=B8=8A=E9=99=90=E9=BB=98=E8=AE=A4=201MB=E2=86=922MB?= =?UTF-8?q?(=E7=AB=AF=E7=82=B9=E9=BB=98=E8=AE=A4=E5=80=BC=20+=20.env.examp?= =?UTF-8?q?le=20+=20=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3=E5=90=8C=E6=AD=A5?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 2 +- app/api/v1/applog.py | 2 +- docs/api/other/applog-batch.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index df4708f..3ed7faf 100644 --- a/.env.example +++ b/.env.example @@ -147,5 +147,5 @@ PANGLE_REPORT_SITE_ID_TEST=5832303 # CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲(给 Logtail 断线留余量) # CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段 # APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启) -# APPLOG_MAX_BODY_BYTES=1048576 # 请求体上限 1MB(超 → 413;运行期可调) +# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调) # APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断 diff --git a/app/api/v1/applog.py b/app/api/v1/applog.py index 787833a..6e9ba83 100644 --- a/app/api/v1/applog.py +++ b/app/api/v1/applog.py @@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/v1/applog", tags=["applog"]) def _enforce_body_limit(request: Request) -> None: """依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。""" - max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024))) + max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024 * 2))) cl = request.headers.get("content-length") if cl is not None and cl.isdigit() and int(cl) > max_bytes: raise HTTPException(status_code=413, detail="日志批量过大") diff --git a/docs/api/other/applog-batch.md b/docs/api/other/applog-batch.md index 8a0e729..ee1271c 100644 --- a/docs/api/other/applog-batch.md +++ b/docs/api/other/applog-batch.md @@ -73,7 +73,7 @@ Mock 出参: ``` ## 错误码 -- `413` 请求体超过上限(默认 1MB;服务端查 `Content-Length`,在 body 校验前拦截。缺该头时由 nginx `client_max_body_size` 兜底) +- `413` 请求体超过上限(默认 2MB;服务端查 `Content-Length`,在 body 校验前拦截。缺该头时由 nginx `client_max_body_size` 兜底) - `422` `logs` 为空或超过 500 条 / `device_id` 缺失 / 字段类型不符 - `429` 触发限流(同 IP 每分钟 > 120 次) @@ -82,6 +82,6 @@ Mock 出参: - **`trace_id` 跨层检索**:字段名与服务端日志一致。客户端应给**有服务端交互**的日志带上当初 API 返回的 `trace_id`(如 `/api/v1/price/step` 等透传族返回的 trace),即可在 SLS 里 `trace_id: "xxx"` 一查拼出「客户端视角 + 服务端比价链路」;纯客户端日志不带即可。 - **白名单 + `data` 兜底**:只有 `client_ts/level/trace_id/tag/msg` 上顶层,其余键并入 `data`,把 SLS 索引列钉死在固定集合,防客户端任意 key 撑爆索引。索引字段另有长度上限(`level`≤16 / `trace_id`≤256 / `tag`≤128 / `msg`≤8KB)。 - **fire-and-forget**:写文件失败也返回 2xx(避免客户端重试风暴);网络重试可能在 SLS 造成重复条目,可接受。 -- **量级建议**:客户端做等级过滤 / 采样,攒到一定量再批量上报;单批 ≤500 条、body ≤1MB。 +- **量级建议**:客户端做等级过滤 / 采样,攒到一定量再批量上报;单批 ≤500 条、body ≤2MB。 - `client_ts` 是端侧时间(客户端时钟不可信),`time` 由服务端补(可靠时间轴)。 - `user_id` 不靠 JWT:未登录态也要采日志;带上便于按用户排查。 -- 2.52.0 From e2a485dfe6a27ae70e72f6ea510c620a8ee1860d Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 10:07:08 +0800 Subject: [PATCH 12/12] =?UTF-8?q?docs(applog):=20spec/plan=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E4=BD=93=E4=B8=8A=E9=99=90=E5=90=8C=E6=AD=A5=E4=B8=BA?= =?UTF-8?q?=202MB(=E4=B8=8E=E7=AB=AF=E7=82=B9=E9=BB=98=E8=AE=A4=E4=B8=80?= =?UTF-8?q?=E8=87=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-19-client-applog-ingest.md | 4 ++-- .../specs/2026-07-19-client-applog-ingest-design.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-07-19-client-applog-ingest.md b/docs/superpowers/plans/2026-07-19-client-applog-ingest.md index cb26eb0..6f915d1 100644 --- a/docs/superpowers/plans/2026-07-19-client-applog-ingest.md +++ b/docs/superpowers/plans/2026-07-19-client-applog-ingest.md @@ -452,7 +452,7 @@ def _client_ip(request: Request) -> str: def _enforce_body_limit(request: Request) -> None: """依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。""" - max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024))) + max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024 * 2))) cl = request.headers.get("content-length") if cl is not None and cl.isdigit() and int(cl) > max_bytes: raise HTTPException(status_code=413, detail="日志批量过大") @@ -532,7 +532,7 @@ git commit -m "feat(applog): POST /api/v1/applog/batch 批量上报端点(限流 # CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲 # CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段 # APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422) -# APPLOG_MAX_BODY_BYTES=1048576 # 请求体上限 1MB(超 → 413) +# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413) # APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超限截断 ``` 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 index 479d27f..26d02e8 100644 --- a/docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md +++ b/docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md @@ -68,7 +68,7 @@ Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网 | 限额 | 默认 | 超限行为 | env | |---|---|---|---| | 单批条数 | 500 | 422(Pydantic) | `APPLOG_MAX_BATCH` | -| body 字节 | 1 MB | 413(依赖查 `Content-Length`,在 body 校验前拦截;缺该头由 nginx `client_max_body_size` 兜底) | `APPLOG_MAX_BODY_BYTES` | +| 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` 关闭。 @@ -154,7 +154,7 @@ Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网 1. 每条日志结构:顶层放 `client_ts / level / trace_id? / tag? / msg`,其余自定义字段放 `data`(否则会被服务端兜底挪进 `data`)。 2. 有服务端交互的日志带上对应 `trace_id`。 3. 等级/采样与 PII 控制在客户端侧做(省流量、免服务端脱敏)。 -4. 单批 ≤500 条、body ≤1MB;失败可重试(服务端接受重复)。 +4. 单批 ≤500 条、body ≤2MB;失败可重试(服务端接受重复)。 ## 11. 代码落点 @@ -174,7 +174,7 @@ Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网 3. **未知键兜底**:记录带 `foo` → 输出行顶层无 `foo`,`data.foo` 存在。 4. **msg 截断**:`msg` > 8KB → 截断+标记,仍 `received`(不进 dropped)。 5. **超批拒绝**:>500 条 → 422。 -6. **超体积拒绝**:`Content-Length` > 1MB → 413。 +6. **超体积拒绝**:`Content-Length` > 2MB → 413。 7. **写失败不 500**:monkeypatch writer 抛错 → 仍 2xx。 8. **不污染服务日志**:`client` logger `propagate=False`,写客户端日志不落 `app-server.log`。 @@ -187,7 +187,7 @@ Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网 | `CLIENT_LOG_BACKUP_COUNT` | `10` | 保留滚动文件数 | | `CLIENT_LOG_SERVICE_NAME` | `app-client` | 输出行 `service` 字段 | | `APPLOG_MAX_BATCH` | `500` | 单批最大条数 | -| `APPLOG_MAX_BODY_BYTES` | `1048576`(1MB) | 请求体上限 | +| `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` 兜默认。 -- 2.52.0