From f05dd1cf748abfdcba55292bdf4904053da52225 Mon Sep 17 00:00:00 2001 From: guke Date: Mon, 27 Jul 2026 18:00:23 +0800 Subject: [PATCH] =?UTF-8?q?docs(applog):=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=A5=E5=BF=97=E6=89=B9=E9=87=8F=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E2=86=92=E8=90=BD=E6=96=87=E4=BB=B6=E2=86=92SLS=20?= =?UTF-8?q?=E9=87=87=E9=9B=86=20=E8=AE=BE=E8=AE=A1(spec)=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/187 --- .env.example | 88 +-- app/api/deps.py | 14 +- app/api/v1/analytics.py | 12 +- app/api/v1/applog.py | 50 ++ app/core/client_log.py | 157 +++++ app/main.py | 2 + app/schemas/applog.py | 31 + docs/api/README.md | 27 +- docs/api/other/applog-batch.md | 87 +++ .../plans/2026-07-19-client-applog-ingest.md | 561 ++++++++++++++++++ .../2026-07-19-client-applog-ingest-design.md | 200 +++++++ tests/test_applog.py | 182 ++++++ 12 files changed, 1301 insertions(+), 110 deletions(-) create mode 100644 app/api/v1/applog.py create mode 100644 app/core/client_log.py create mode 100644 app/schemas/applog.py create mode 100644 docs/api/other/applog-batch.md create mode 100644 docs/superpowers/plans/2026-07-19-client-applog-ingest.md create mode 100644 docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md create mode 100644 tests/test_applog.py diff --git a/.env.example b/.env.example index 6a0191f..3ed7faf 100644 --- a/.env.example +++ b/.env.example @@ -27,55 +27,7 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify JG_REQUEST_TIMEOUT_SEC=15 -# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)===== -# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。 -# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。 -ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia -PUSH_REQUEST_TIMEOUT_SEC=15 -PUSH_TIME_TO_LIVE_SEC=86400 - -HONOR_PUSH_APP_ID= -HONOR_PUSH_CLIENT_ID= -HONOR_PUSH_CLIENT_SECRET= -HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token -HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage - -# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret -HUAWEI_PUSH_APP_ID= -HUAWEI_PUSH_APP_SECRET= -HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token -HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send - -VIVO_PUSH_APP_ID= -VIVO_PUSH_APP_KEY= -VIVO_PUSH_APP_SECRET= -VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth -VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send -# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。 -VIVO_PUSH_MODE=1 -VIVO_PUSH_NOTIFY_TYPE=4 -VIVO_PUSH_CATEGORY=DEVICE_REMINDER - -XIAOMI_PUSH_APP_SECRET= -XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid -XIAOMI_PUSH_CHANNEL_ID= -XIAOMI_PUSH_TEMPLATE_ID= -XIAOMI_PUSH_TEMPLATE_TITLE= -XIAOMI_PUSH_TEMPLATE_DESCRIPTION= -# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"} -XIAOMI_PUSH_TEMPLATE_PARAM_JSON= - -OPPO_PUSH_APP_KEY= -OPPO_PUSH_MASTER_SECRET= -OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth -OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast -# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」; -# notify_level 0=不传走默认,内容营销类仅支持 1/2) -OPPO_PUSH_CHANNEL_ID= -OPPO_PUSH_CATEGORY= -OPPO_PUSH_NOTIFY_LEVEL=0 - -# ===== 无障碍保护存活监控(推送 + pull 后置兜底)===== +# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)===== HEARTBEAT_MONITOR_ENABLED=true HEARTBEAT_TIMEOUT_MINUTES=60 HEARTBEAT_SCAN_INTERVAL_SEC=60 @@ -113,13 +65,6 @@ JD_UNION_APP_SECRET= JD_UNION_SITE_ID= JD_UNION_AUTH_KEY= -# 美团 + 京东订单每天北京时间 05:00 自动对账;按更新时间回拉近 3 天,重叠防漏单并刷新状态。 -# 手动对账按钮不受该开关影响。通常保持开启;临时停自动任务时设为 false。 -CPS_AUTO_RECONCILE_ENABLED=true -CPS_AUTO_RECONCILE_RUN_HOUR=5 -CPS_AUTO_RECONCILE_LOOKBACK_DAYS=3 -CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC=60 - # ===== Pricebot 上游 (领券/比价业务透传目标) ===== # 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。 # 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。 @@ -139,11 +84,6 @@ PRICEBOT_COMPARE_TIMEOUT_SEC=60 # 必须与 pricebot 侧的 INTERNAL_API_SECRET **同值**;留空 = 内部写端点关闭(返 503)。 # 启用前两边都填同一高熵串:python -c "import secrets; print(secrets.token_urlsafe(48))" INTERNAL_API_SECRET= -# 新版比价 harvest 完成后即时回填;以下 worker 再补偿短暂故障期间漏掉的记录。 -LLM_COST_BACKFILL_ENABLED=true -LLM_COST_BACKFILL_INTERVAL_SEC=300 -LLM_COST_BACKFILL_BATCH_SIZE=100 -LLM_COST_BACKFILL_LOOKBACK_DAYS=30 # ===== CORS ===== # 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类 @@ -198,18 +138,14 @@ PANGLE_REPORT_SECURITY_KEY= PANGLE_REPORT_SITE_ID_PROD=5830519 PANGLE_REPORT_SITE_ID_TEST=5832303 -# ===== 可观测(OpenObserve 接口指标)===== -# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。 -# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。 -# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务。 -OBSERVE_ENABLED=false -OBSERVE_ENDPOINT=http://localhost:5080 -OBSERVE_ORG=default -OBSERVE_STREAM=app_requests -OBSERVE_USER=admin@shaguabijia.local -OBSERVE_PASSWORD=Complexpass#123 -# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒 -OBSERVE_FLUSH_INTERVAL_SEC=5 -OBSERVE_BATCH_MAX=200 -OBSERVE_QUEUE_MAX=10000 -OBSERVE_TIMEOUT_SEC=5 +# ===== 客户端运行日志上报(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=2097152 # 请求体上限 2MB(超 → 413;运行期可调) +# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断 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 new file mode 100644 index 0000000..6e9ba83 --- /dev/null +++ b/app/api/v1/applog.py @@ -0,0 +1,50 @@ +"""客户端运行日志批量上报接口。 + +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.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 + +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 * 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="日志批量过大") + + +@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=get_client_ip(request), + ) + return AppLogIngestOut(received=received, dropped=dropped) diff --git a/app/core/client_log.py b/app/core/client_log.py new file mode 100644 index 0000000..be9772a --- /dev/null +++ b/app/core/client_log.py @@ -0,0 +1,157 @@ +"""客户端运行日志专用落盘 writer(独立于服务端 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 二次编码。 +- 滚动 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") + +# 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 + + +def _max_msg_bytes() -> int: + # 每次调用现读 env(不设模块级常量):便于运行期调整 / 测试 monkeypatch,开销可忽略。 + return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192")) + + +def _build_logger() -> logging.Logger: + 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( + 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 + # 白名单键提顶层(索引字段做长度上限 + 统一转 str,保证 SLS 里类型/大小可控) + if record.get("level") is not None: + out["level"] = str(record["level"])[:_MAX_LEVEL_LEN].upper() + if record.get("trace_id"): + out["trace_id"] = str(record["trace_id"])[:_MAX_TRACE_ID_LEN] + if record.get("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: + msg, truncated = _truncate_msg(str(record["msg"])) + out["msg"] = msg + if truncated: + out["msg_truncated"] = True + # 其余键并入 data:先收白名单外的散键(兜底),再让客户端显式的 data 覆盖同名散键 + # —— 显式 data 为准,不静默丢客户端明确给的值(保真)。 + data: dict = {} + 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) + + +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 — 坏条跳过,不影响其余 + logging.getLogger("shagua.applog").exception( + "client log record dropped client_ip=%s", client_ip + ) + dropped += 1 + return received, dropped diff --git a/app/main.py b/app/main.py index 5cc5f62..b93fe69 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 @@ -153,6 +154,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..9bbb9d4 --- /dev/null +++ b/app/schemas/applog.py @@ -0,0 +1,31 @@ +"""客户端运行日志批量上报 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 + +# 单批条数上限。注意:这是**导入期**常量(Pydantic Field(max_length=) 在类定义时求值), +# 改它需重启进程;要运行期可调的上限用 APPLOG_MAX_BODY_BYTES(端点里 call-time 读)。 +_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/docs/api/README.md b/docs/api/README.md index 03059c9..26e911e 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,13 +1,9 @@ # 傻瓜比价 App 后端 — API 接口文档(索引) > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` -> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档) +> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` -<<<<<<< HEAD -> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点) -======= > 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03) ->>>>>>> origin/main > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 --- @@ -82,6 +78,7 @@ | **签到**(前缀 `/api/v1/signin`) ||| | 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) | | 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) | +| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) | | **任务**(前缀 `/api/v1/tasks`) ||| | 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) | | 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) | @@ -92,7 +89,6 @@ | **看广告发奖**(前缀 `/api/v1/ad`) ||| | 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) | | 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) | -| 33a | `GET /api/v1/ad/reward-result/{ad_session_id}` | Bearer | [详情](./ad/ad-reward-result.md)(本次实发金币 + 本轮膨胀累计 `round_coin`,弹窗数字用它) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) | | 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) | @@ -107,33 +103,19 @@ | 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114) | | 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) | | **帮助与反馈**(前缀 `/api/v1/feedback`) ||| -<<<<<<< HEAD -| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) | -| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) | -| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) | -| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) ||| -| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) | -| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) | -| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) | -| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase) ||| -| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) | -| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) | -| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) | -======= | 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元) | ->>>>>>> origin/main +| 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) | | 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) | | 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) | | 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) | -| 40d | `GET /api/v1/platform/huawei-review` | 无 | [详情](./platform/platform-huawei-review.md)(华为审核开关:快速设置权限步能否被用户关闭;仅华为 ROM 客户端拉) | | **微信支付回调**(前缀 `/api/v1/wxpay`) ||| | W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) | | **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) ||| @@ -172,7 +154,6 @@ | A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) | | A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) | | A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) | -| A16 | `GET / PATCH /admin/api/huawei-review` | operator/tech | 华为审核开关(快速设置权限步能否被用户关闭,落 `app_config.huawei_review`;C 端只读版见 40d)(无单独文档,见 `app/admin/routers/huawei_review.py`) | | **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) | | A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) | | **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) | diff --git a/docs/api/other/applog-batch.md b/docs/api/other/applog-batch.md new file mode 100644 index 0000000..ee1271c --- /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` 请求体超过上限(默认 2MB;服务端查 `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 ≤2MB。 +- `client_ts` 是端侧时间(客户端时钟不可信),`time` 由服务端补(可靠时间轴)。 +- `user_id` 不靠 JWT:未登录态也要采日志;带上便于按用户排查。 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..6f915d1 --- /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 * 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="日志批量过大") + + +@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=2097152 # 请求体上限 2MB(超 → 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 new file mode 100644 index 0000000..26d02e8 --- /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 字节 | 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 表关联。 diff --git a/tests/test_applog.py b/tests/test_applog.py new file mode 100644 index 0000000..1d96037 --- /dev/null +++ b/tests/test_applog.py @@ -0,0 +1,182 @@ +"""客户端运行日志上报: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]") + body = line["msg"].removesuffix("…[truncated]") + 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" + 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,且不抛给上层 + + +# ---------------- 端点层 ---------------- + +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): + # 只测有 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) + 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 必填