"""客户端运行日志专用落盘 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