事故:一条日志带走了手机号、订单号与令牌
《潮汐方舟》的一次充值未到账排查里,客服要求研发拉取玩家日志。日志确实定位到了问题:订单回调到达时,客户端已经切到后台,恢复后本地状态没有重放。但同一条日志里还带着玩家标识、订单号、请求令牌,以及玩家在反馈框里输入的手机号。隐私评审当场拦下了上传。
案例设定里,方舟是强联网中重度产品,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。级别失去区分度,采样只能一刀切,关键错误会被普通日志淹没。
- 用随机数采样。同一条事件重试时结论会翻转,排障看到的日志不完整且不可复现。
- 只按会话采样,不限流。循环或重试风暴会把配额打满,真正有用的前后文被挤掉。
- 先落盘再脱敏。原始手机号、令牌已经写入本地文件,后续删除无法保证彻底。
- 把未知字段默认放行。新加的调试字段最容易带出个人信息,白名单之外必须丢弃。
- 假名盐值写进客户端。攻击者可以枚举还原;盐值应在服务端或安全配置中保管并按日轮换。
- 自由文本当普通字段。玩家输入可能包含手机号、邮箱与账号,必须丢弃或做兜底清洗。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。