《移动游戏产品的架构·客户端与移动运行时》12.2 日志分级、采样与隐私脱敏

日志不是越多越好。方舟的内购排查需要订单上下文,但原始日志同时带走了手机号、令牌与玩家自由文本。本节用分级、确定性采样、字段白名单与按日轮换的假名化,给出可运行的采样与脱敏实现,并说明采样与脱敏的先后顺序,以及客户端与服务端两层脱敏的职责边界。

事故:一条日志带走了手机号、订单号与令牌

《潮汐方舟》的一次充值未到账排查里,客服要求研发拉取玩家日志。日志确实定位到了问题:订单回调到达时,客户端已经切到后台,恢复后本地状态没有重放。但同一条日志里还带着玩家标识、订单号、请求令牌,以及玩家在反馈框里输入的手机号。隐私评审当场拦下了上传。

案例设定里,方舟是强联网中重度产品,DAU 25 万,内购占收入 90%,单场 8–12 分钟,日志天然带着大量订单与账号上下文。这类产品的可观测性不能只问「记不记得下」,还要问「该不该记、该记多细、记完给谁看」。分级决定记不记,采样决定记多少,脱敏决定能留下什么。

级别与采样率

级别不是装饰。每一级都要有明确的线上保留率与使用场景,否则研发会把所有东西写成 info,采样率再高也救不回来。

级别典型用途线上保留率说明
trace逐帧与逐请求细节0%只在本地调试构建开启
debug状态机迁移、分支选择5%灰度期可临时提高,正式包保持低位
info关键业务节点20%关卡开始结束、订单状态变化
warn可恢复异常80%重试、降级、弱网切换
error请求失败、逻辑错误100%必须能还原失败路径
fatal崩溃前最后一条100%与崩溃转储一起落盘

采样必须是确定性的:同一个会话、同一个事件名,无论重试多少次,落桶结果都一样。否则一次失败请求重试三次,可能两次被采样、一次被丢弃,排障时看到的日志自相矛盾。模型用 hash(sessionId|event) 落桶,桶号落在保留率区间内才保留,重试不会改变结论。

确定性采样解决「同一条事件是否保留」,不解决「同一事件刷屏」。还需要按会话与事件名做上限:例如每个会话每个事件最多保留 20 条,超过后丢弃并计数。刷屏通常意味着循环或重试风暴,计数本身就是信号。

脱敏:按字段规则,不靠正则碰运气

脱敏不能只在导出时做一次。字段在进入缓冲区之前就要处理,否则原始值已经落盘,后续再删也收不回来。

字段分类处理原因
用户标识个人加盐假名,按日轮换需要跨事件关联,但不能还原到账号
设备标识个人加盐假名,按日轮换同上,且不采集广告标识
手机号个人保留前三后四客服核对需要,完整号码不必要
邮箱个人保留首字母与域名同上
IP个人截断到子网定位区域足够,精确地址不必要
令牌、密码秘密丢弃任何日志都不应出现
自由文本、昵称秘密丢弃可能包含玩家输入的个人信息
订单号准标识加盐假名可关联同一订单的多条日志
未知字段未知丢弃白名单之外默认不可信

白名单是最后一道闸门:只有明确列出的运营字段可以通过,未知字段一律丢弃。这里有一个容易忽略的顺序问题——已知敏感字段要先命中规则再处理,不能因为不在白名单里就直接丢弃;用户标识需要假名化后参与关联,直接丢弃会让排障失去线索。

假名化使用应用侧盐值与日期。同一天内同一标识稳定,跨天轮换,避免长期跟踪;盐值放在安全配置里,不写进日志,也不随包分发到客户端明文。自由文本即使不采集,也要在采集侧做一次兜底清洗,防止某个字段被误标为安全字段后把邮箱、手机号带出去。

采样与脱敏的顺序

正确顺序是:级别过滤 → 采样判定 → 白名单与字段脱敏 → 写入缓冲区。采样判定需要原始会话标识计算桶号,这一步只在内存里发生;一旦决定保留,字段在写盘前就完成脱敏。

反过来先脱敏再采样也能工作,但会话标识已经被轮换,重试跨天时桶号会变化,确定性被破坏。先采样再脱敏则要求采样阶段不把原始字段写进任何持久化位置,模型用纯函数保证这一点。

还要区分「客户端脱敏」与「服务端脱敏」。客户端脱敏负责不让原始值离开设备,服务端脱敏负责入仓前的二次检查。两层都做不是浪费:客户端可能被改包,服务端可能收到旧版本客户端的裸字段,任何一层都不能假设另一层已经做好。

可运行模型:sampling.ts

下面的模型实现分级、确定性采样、按事件限流、字段白名单与四类脱敏动作。它是纯函数与内存状态,不涉及真实日志管道与密钥管理;盐值轮换、密钥托管与合规审计需要平台侧配合,这里是模型,不是生产配置。

// file: src/obs/sampling.ts
// tier: L1
// verify: node --test test/obs/sampling.test.ts

export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";

export const LEVEL_RANK: Readonly<Record<LogLevel, number>> = {
  trace: 10,
  debug: 20,
  info: 30,
  warn: 40,
  error: 50,
  fatal: 60,
};

export interface SamplingPolicy {
  readonly minLevel: LogLevel;
  readonly rates: Readonly<Record<LogLevel, number>>;
  readonly maxPerEventPerSession: number;
}

export const DEFAULT_POLICY: SamplingPolicy = {
  minLevel: "info",
  rates: { trace: 0, debug: 0.05, info: 0.2, warn: 0.8, error: 1, fatal: 1 },
  maxPerEventPerSession: 20,
};

export interface LogRecord {
  readonly level: LogLevel;
  readonly event: string;
  readonly sessionId: string;
  readonly tsMs: number;
  readonly fields: Readonly<Record<string, unknown>>;
}

export type DropReason = "below-level" | "sampled-out" | "rate-limited";
export type KeepReason = "full-rate" | "sampled";

export interface Decision {
  readonly keep: boolean;
  readonly reason: KeepReason | DropReason;
  readonly bucket: number;
}

export function hash32(text: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash >>> 0;
}

export function bucketOf(sessionId: string, event: string): number {
  return hash32(`${sessionId}|${event}`) % 10_000;
}

export class LogSampler {
  readonly #policy: SamplingPolicy;
  readonly #counts = new Map<string, number>();

  constructor(policy: SamplingPolicy = DEFAULT_POLICY) {
    this.#policy = policy;
  }

  decide(record: LogRecord): Decision {
    if (LEVEL_RANK[record.level] < LEVEL_RANK[this.#policy.minLevel]) {
      return { keep: false, reason: "below-level", bucket: -1 };
    }
    const rate = this.#policy.rates[record.level];
    if (rate <= 0) {
      return { keep: false, reason: "sampled-out", bucket: -1 };
    }
    const bucket = bucketOf(record.sessionId, record.event);
    if (rate < 1 && bucket >= Math.floor(rate * 10_000)) {
      return { keep: false, reason: "sampled-out", bucket };
    }
    const key = `${record.sessionId}|${record.event}`;
    const count = this.#counts.get(key) ?? 0;
    if (count >= this.#policy.maxPerEventPerSession) {
      return { keep: false, reason: "rate-limited", bucket };
    }
    this.#counts.set(key, count + 1);
    return { keep: true, reason: rate >= 1 ? "full-rate" : "sampled", bucket };
  }

  countOf(sessionId: string, event: string): number {
    return this.#counts.get(`${sessionId}|${event}`) ?? 0;
  }

  resetSession(sessionId: string): void {
    const prefix = `${sessionId}|`;
    for (const key of [...this.#counts.keys()]) {
      if (key.startsWith(prefix)) {
        this.#counts.delete(key);
      }
    }
  }
}

export function maskEmail(value: string): string {
  const at = value.indexOf("@");
  if (at <= 0) {
    return "[email]";
  }
  const local = value.slice(0, at);
  const domain = value.slice(at + 1);
  return `${local.slice(0, 1)}***@${domain}`;
}

export function maskPhone(value: string): string {
  const digits = value.replace(/\D/g, "");
  if (digits.length < 7) {
    return "[phone]";
  }
  return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}

export function truncateIp(value: string): string {
  if (value.includes(":")) {
    const groups = value.split(":").filter((group) => group.length > 0);
    if (groups.length < 3) {
      return "[ip]";
    }
    return `${groups.slice(0, 3).join(":")}::`;
  }
  const parts = value.split(".");
  if (parts.length !== 4) {
    return "[ip]";
  }
  return `${parts[0]}.${parts[1]}.${parts[2]}.0`;
}

export function pseudonymize(value: string, salt: string, day: string): string {
  const left = hash32(`${salt}:${day}:${value}`).toString(16).padStart(8, "0");
  const right = hash32(`${value}:${day}:${salt}`).toString(16).padStart(8, "0");
  return `${left}${right}`;
}

export function scrubText(text: string): string {
  return text
    .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "[email]")
    .replace(/(?<!\d)1[3-9]\d{9}(?!\d)/g, "[phone]")
    .replace(/\b\d{15,18}\b/g, "[id]")
    .replace(/\b(?:token|key|secret|password)\s*=\s*[^&\s]+/gi, "[secret]")
    .replace(/\b[0-9a-f]{32,}\b/gi, "[hex]");
}

export type RedactionMode = "mask" | "hash" | "truncate-ip" | "drop";

export interface RedactionRule {
  readonly pattern: RegExp;
  readonly mode: RedactionMode;
}

export const REDACTION_RULES: readonly RedactionRule[] = [
  { pattern: /^(user_?id|uid|account_?id|role_?id)$/i, mode: "hash" },
  { pattern: /^(device_?id|imei|idfa|aaid|oaid|android_?id|mac_?addr(ess)?|installation_?id)$/i, mode: "hash" },
  { pattern: /^(phone|mobile|tel)$/i, mode: "mask" },
  { pattern: /^(email|mail)$/i, mode: "mask" },
  { pattern: /^(ip|client_?ip|remote_?addr)$/i, mode: "truncate-ip" },
  { pattern: /^(token|access_?token|refresh_?token|session_?token|password|passwd|secret|auth|signature)$/i, mode: "drop" },
  { pattern: /^(order_?id|transaction_?id|pay_?id)$/i, mode: "hash" },
  { pattern: /^(free_?text|message|comment|nickname|player_?name|chat)$/i, mode: "drop" },
];

export const SAFE_FIELDS: ReadonlySet<string> = new Set([
  "event",
  "level",
  "tsMs",
  "sessionId",
  "appVersion",
  "osVersion",
  "deviceModel",
  "channel",
  "region",
  "scene",
  "errorCode",
  "durationMs",
  "network",
  "result",
]);

export function redactField(key: string, value: unknown, salt: string, day: string): string | undefined {
  const rule = REDACTION_RULES.find((item) => item.pattern.test(key));
  const text = typeof value === "string" ? value : String(value);
  if (!rule) {
    return scrubText(text);
  }
  switch (rule.mode) {
    case "hash":
      return pseudonymize(text, salt, day);
    case "mask":
      return /mail|email/i.test(key) ? maskEmail(text) : maskPhone(text);
    case "truncate-ip":
      return truncateIp(text);
    case "drop":
      return undefined;
  }
}

export interface RedactedRecord {
  readonly level: LogLevel;
  readonly event: string;
  readonly sessionId: string;
  readonly tsMs: number;
  readonly fields: Readonly<Record<string, string>>;
  readonly dropped: readonly string[];
}

export function redactRecord(
  record: LogRecord,
  salt: string,
  day: string,
  allow: ReadonlySet<string> = SAFE_FIELDS,
): RedactedRecord {
  const fields: Record<string, string> = {};
  const dropped: string[] = [];
  for (const [key, value] of Object.entries(record.fields)) {
    const knownSensitive = REDACTION_RULES.some((item) => item.pattern.test(key));
    if (!knownSensitive && !allow.has(key)) {
      dropped.push(key);
      continue;
    }
    const redacted = redactField(key, value, salt, day);
    if (redacted === undefined) {
      dropped.push(key);
      continue;
    }
    fields[key] = redacted;
  }
  return {
    level: record.level,
    event: record.event,
    sessionId: pseudonymize(record.sessionId, salt, day),
    tsMs: record.tsMs,
    fields,
    dropped,
  };
}

与相邻章节的关系

12.1 定义了崩溃转储里哪些字段可以采,本节把同一套字段策略扩展到普通日志;12.3 会用日志与事件指纹做定位闭环。埋点口径可以参考埋点规范 ,合规工程的落地可以参考 GDPR 与 CCPA 工程化 ,内购链路的日志边界可以参考内购合规 。

小结

  • 级别决定记不记,采样决定记多少,脱敏决定能留下什么;三者顺序不能颠倒。
  • 采样必须确定性:同一会话同一事件重试多少次都得到同一个结论,否则日志自相矛盾。
  • 采样率要按级别设置,error 与 fatal 全量保留;低频关键事件不应被高比例采样误伤。
  • 脱敏在写盘前完成,使用字段白名单;未知字段默认丢弃,已知敏感字段先假名化再参与关联。
  • 假名化按日轮换,盐值不进日志;客户端与服务端各做一层脱敏,不能互相假设。

阅读导航:上一节:12.1 崩溃、ANR 与卡顿的采集边界 · 下一节:12.3 线上问题定位闭环 。

常见错误与边界

  • 所有日志都写成 info。级别失去区分度,采样只能一刀切,关键错误会被普通日志淹没。
  • 用随机数采样。同一条事件重试时结论会翻转,排障看到的日志不完整且不可复现。
  • 只按会话采样,不限流。循环或重试风暴会把配额打满,真正有用的前后文被挤掉。
  • 先落盘再脱敏。原始手机号、令牌已经写入本地文件,后续删除无法保证彻底。
  • 把未知字段默认放行。新加的调试字段最容易带出个人信息,白名单之外必须丢弃。
  • 假名盐值写进客户端。攻击者可以枚举还原;盐值应在服务端或安全配置中保管并按日轮换。
  • 自由文本当普通字段。玩家输入可能包含手机号、邮箱与账号,必须丢弃或做兜底清洗。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查