上线一周,日志账单涨了二十倍
某工具类应用在 6.0 版本接入了新的埋点 SDK。为了“先看清楚”,团队把日志级别开到 debug,所有页面、所有接口、所有状态变更都上报。上线一周,日志量从每天 80 GB 涨到 1.6 TB,账单跟着涨了二十倍。
更麻烦的是合规扫描:日志里出现了用户邮箱、手机号、身份证号和完整地址。安全团队要求立刻停止上报,业务团队要求保留排查能力,两边僵住。
这不是“日志太多”的问题,而是三个问题被混在一起:
- 采样没有分级。
debug和fatal用同一个开关,要么全开,要么全关。 - 脱敏没有入口。 各业务线自己拼属性,有的脱了邮箱,有的漏了手机号。
- 成本没有口径。 没人知道一条事件多少字节、一天多少条、按什么单价估算。
三张策略表
端上采样与成本控制,先要三张表:采样率、脱敏规则、保留天数。三张表都按等级或字段类型定义,而不是按业务线各写一套。
| 等级 | 默认采样率 | 保留天数 | 理由 |
|---|---|---|---|
debug | 1% | 3 天 | 量大、价值低,只用于短期复现 |
info | 10% | 14 天 | 支撑转化漏斗,抽样后仍可估计总量 |
warn | 50% | 30 天 | 异常信号,需要较高保真度 |
error | 100% | 90 天 | 每次失败都要能定位 |
fatal | 100% | 180 天 | 崩溃与资损,保留最长 |
采样率不是拍脑袋定的,它由两个约束共同决定:排查需要多少样本,以及预算允许多少字节。error 与 fatal 全量保留,是因为它们的总量天然有限;debug 压到 1%,是因为它的总量最大、单条价值最低。
脱敏规则按字段类型划分:
| 字段类型 | 识别方式 | 处理 |
|---|---|---|
| 邮箱 | 正则匹配 | 整体替换为 [REDACTED] |
| 手机号 | 11 位、以 1 开头 | 整体替换 |
| 身份证 | 18 位,末位可为 X | 整体替换 |
| IPv4 | 四段数字 | 整体替换 |
| 敏感键名 | 键名包含 email、phone、token 等 | 不检查内容,直接替换 |
最后一行是关键:不能只靠正则。用户可能把手机号写进 note,也可能把 token 放进 trace。键名命中敏感词时直接整体替换,正则只作为第二道防线。
采样必须确定性
最容易被忽略的一条:采样决策必须可复现。如果每次调用都重新随机,同一个会话的前半段被保留、后半段被丢弃,漏斗就断了,用户路径也无法还原。
代码用事件 id 的哈希做桶,而不是 Math.random():
// file: src/observability/sampling-cost.ts
// tier: L1
// verify: node --test test/observability/sampling-cost.test.ts
import { Buffer } from "node:buffer";
export type Severity = "debug" | "info" | "warn" | "error" | "fatal";
export type TelemetryEvent = {
id: string;
name: string;
severity: Severity;
timestamp: number;
attributes?: Record<string, string>;
};
export type SampleDecision = { keep: boolean; rate: number; reason: "always" | "rate" };
export type SamplingReport = {
total: number;
kept: number;
dropped: number;
bySeverity: Record<Severity, { kept: number; dropped: number }>;
};
export type CostConfig = { pricePerGb: number; scaleFactor?: number };
export type CostEstimate = { events: number; bytes: number; gigabytes: number; cost: number };
export type RetentionPolicy = Record<Severity, number>;
export type RetentionPartition = { kept: TelemetryEvent[]; expired: TelemetryEvent[] };
export const SEVERITIES: Severity[] = ["debug", "info", "warn", "error", "fatal"];
export const DEFAULT_RATES: Record<Severity, number> = {
debug: 0.01,
info: 0.1,
warn: 0.5,
error: 1,
fatal: 1,
};
export const DEFAULT_RETENTION_DAYS: RetentionPolicy = {
debug: 3,
info: 14,
warn: 30,
error: 90,
fatal: 180,
};
export const DAY_MS = 24 * 60 * 60 * 1000;
export const REDACTED = "[REDACTED]";
export const SENSITIVE_KEYS = ["email", "phone", "mobile", "idcard", "id_card", "address", "token", "password"];
const PII_PATTERNS: RegExp[] = [
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
/(?<!\d)\d{17}[\dXx](?!\d)/g,
/(?<!\d)1[3-9]\d{9}(?!\d)/g,
/(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)/g,
];
export function stableHash(input: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
export function sampleDecision(
key: string,
severity: Severity,
rates: Record<Severity, number> = DEFAULT_RATES,
): SampleDecision {
const rate = rates[severity];
if (rate >= 1) {
return { keep: true, rate: 1, reason: "always" };
}
if (rate <= 0) {
return { keep: false, rate: 0, reason: "always" };
}
return { keep: stableHash(key) % 10000 < Math.round(rate * 10000), rate, reason: "rate" };
}
export function keepEvent(event: TelemetryEvent, rates: Record<Severity, number> = DEFAULT_RATES): boolean {
return sampleDecision(event.id, event.severity, rates).keep;
}
export function samplingReport(
events: TelemetryEvent[],
rates: Record<Severity, number> = DEFAULT_RATES,
): SamplingReport {
const bySeverity = {} as Record<Severity, { kept: number; dropped: number }>;
for (const severity of SEVERITIES) {
bySeverity[severity] = { kept: 0, dropped: 0 };
}
let kept = 0;
let dropped = 0;
for (const event of events) {
if (keepEvent(event, rates)) {
bySeverity[event.severity].kept += 1;
kept += 1;
} else {
bySeverity[event.severity].dropped += 1;
dropped += 1;
}
}
return { total: events.length, kept, dropped, bySeverity };
}
export function redactText(text: string): string {
let output = text;
for (const pattern of PII_PATTERNS) {
output = output.replace(pattern, REDACTED);
}
return output;
}
function sensitiveKey(key: string): boolean {
const normalized = key.toLowerCase().replace(/[^a-z0-9_]/g, "");
return SENSITIVE_KEYS.some((candidate) => normalized.includes(candidate));
}
export function redactAttributes(attributes: Record<string, string>): Record<string, string> {
const output: Record<string, string> = {};
for (const key of Object.keys(attributes)) {
output[key] = sensitiveKey(key) ? REDACTED : redactText(attributes[key]);
}
return output;
}
export function redactEvent(event: TelemetryEvent): TelemetryEvent {
const copy: TelemetryEvent = {
id: event.id,
name: redactText(event.name),
severity: event.severity,
timestamp: event.timestamp,
};
if (event.attributes) {
copy.attributes = redactAttributes(event.attributes);
}
return copy;
}
export function estimateBytes(event: TelemetryEvent): number {
return Buffer.byteLength(JSON.stringify(event), "utf8");
}
export function estimateCost(events: TelemetryEvent[], config: CostConfig): CostEstimate {
const bytes = events.reduce((sum, event) => sum + estimateBytes(event), 0);
const scaledBytes = bytes * (config.scaleFactor ?? 1);
const gigabytes = scaledBytes / (1024 * 1024 * 1024);
return { events: events.length, bytes: scaledBytes, gigabytes, cost: gigabytes * config.pricePerGb };
}
export function retentionDays(severity: Severity, policy: RetentionPolicy = DEFAULT_RETENTION_DAYS): number {
return policy[severity];
}
export function expiresAt(event: TelemetryEvent, policy: RetentionPolicy = DEFAULT_RETENTION_DAYS): number {
return event.timestamp + retentionDays(event.severity, policy) * DAY_MS;
}
export function isExpired(event: TelemetryEvent, now: number, policy: RetentionPolicy = DEFAULT_RETENTION_DAYS): boolean {
return now >= expiresAt(event, policy);
}
export function partitionByRetention(
events: TelemetryEvent[],
now: number,
policy: RetentionPolicy = DEFAULT_RETENTION_DAYS,
): RetentionPartition {
const kept: TelemetryEvent[] = [];
const expired: TelemetryEvent[] = [];
for (const event of events) {
(isExpired(event, now, policy) ? expired : kept).push(event);
}
return { kept, expired };
}
export function bytesBySeverity(events: TelemetryEvent[]): Record<Severity, number> {
const out = {} as Record<Severity, number>;
for (const severity of SEVERITIES) {
out[severity] = 0;
}
for (const event of events) {
out[event.severity] += estimateBytes(event);
}
return out;
}
stableHash 是 FNV-1a 的 32 位实现,同一个 key 永远落在同一个桶里。sampleDecision 把比率换算成万分比,bucket < rate * 10000 就保留。这样有三个直接好处:
- 同一会话、同一事件 id 的决策稳定,重试不会一会儿保留一会儿丢弃。
- 不同端可以用同一个 key 规则,服务端能按同样口径复算。
- 采样率调整后,只有跨越新边界的 key 会改变,不会全量抖动。
error 与 fatal 的比率是 1,函数直接返回 reason: "always",不进入哈希计算。这不是性能优化,而是语义:这两类事件不允许被采样丢弃。
脱敏放在上报之前
redactText、redactAttributes、redactEvent 三个函数都返回新对象,不修改入参。redactEvent 先处理 name,再处理属性。属性分两步:键名敏感的直接替换,其余走正则。顺序不能反——如果先跑正则,user_email 这种键名不会被识别,但它的值可能恰好不是标准邮箱格式,于是漏掉。
不修改入参是硬要求。上报 SDK 经常在脱敏之后继续用原对象做本地缓存或调试输出,如果函数原地修改,本地缓存也会被污染,排查时看到的是 [REDACTED],反而丢失证据。
脱敏也有边界:正则只能覆盖常见格式,不能保证 100%。真正的兜底应该在接收端再做一次,并且对疑似 PII 的字段做抽样审计。客户端脱敏的目标是“不让明文离开设备”,不是“替代服务端治理”。
成本估算的口径
成本估算只需要三个数:样本字节数、放大倍数、单价。estimateBytes 把单条事件序列化成 UTF-8 字节,estimateCost 先汇总样本字节,再乘以 scaleFactor,最后按 pricePerGb 换算成金额。
举例说明口径,不代表任何厂商报价:假设抽样得到 5000 条事件,合计 4.25 MB,实际日量为样本的 200 倍,则日字节约 850 MB;按 0.4 元/GB 的假设单价,日成本约 0.33 元,30 天约 10 元。这个数字很小,但它把“日志很便宜”变成了可复核的算式——把日量换成 1.6 TB,结论立刻反过来。
估算有三个必须写清楚的假设:
scaleFactor是样本到全量的放大倍数,不是采样率的倒数本身;两者混淆会差一个数量级。- 单价通常按压缩后或未压缩字节计价,不同厂商口径不同,必须注明。
- 保留天数影响的是存储成本,不是 ingest 成本;两者要分开算。
保留策略与删除
保留策略按等级配置,expiresAt 计算到期时间,isExpired 判断是否过期,partitionByRetention 把一批事件拆成保留与过期两组。
这里有两个容易踩的边界:
- 用事件时间,不用入库时间。 客户端可能离线缓存几天再上报,用入库时间会让老事件“续命”。
- 到期判定用
>=。 恰好到期的时刻就应该算过期,否则边界事件会多留一个调度周期。
partitionByRetention 返回保留与过期两个数组,调用方可以据此做删除、归档或降采样。删除必须是幂等的:同一批事件重复处理,结果不变。
测试与真实输出
✔ 默认采样率随等级单调不减 (0.575958ms)
✔ error 与 fatal 全量保留 (0.402875ms)
✔ 同一 key 的决策稳定 (0.142583ms)
✔ stableHash 对同一输入返回同一结果 (0.072625ms)
✔ debug 采样率约为百分之一 (1.22175ms)
✔ 采样率为零时全部丢弃 (0.093041ms)
✔ 采样率为一时全部保留且标记 always (0.055542ms)
✔ 采样统计守恒且按等级分桶 (0.114666ms)
✔ 邮箱与手机号被脱敏 (0.266042ms)
✔ 身份证号被脱敏 (0.108209ms)
✔ IPv4 地址被脱敏 (0.059458ms)
✔ 敏感键名直接整体脱敏 (0.109041ms)
✔ 脱敏不修改原对象 (0.246875ms)
✔ redactEvent 同时清理名称与属性 (0.064291ms)
✔ 字节估算为正且随字段增长 (0.086667ms)
✔ 成本估算按缩放因子线性增长 (0.084875ms)
✔ 成本估算与千兆字节换算一致 (0.051166ms)
✔ 默认保留天数按等级递减 (0.049125ms)
✔ 到期时间按事件时间加保留天数 (0.047666ms)
✔ 到期判定在边界时刻生效 (0.04925ms)
✔ 保留策略拆分保留与过期事件 (0.1325ms)
✔ 自定义保留策略覆盖默认值 (0.043583ms)
✔ 按等级统计字节数覆盖全部等级 (0.065666ms)
ℹ tests 23
ℹ suites 0
ℹ pass 23
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 136.694416
23 个用例覆盖了四组行为:采样决策的稳定性与分布、脱敏的字段覆盖与不可变性、成本换算的线性关系、保留策略的边界。其中“debug 采样率约为百分之一”用 5000 个 key 做分布检查,允许 20 到 90 个命中;这个区间足够宽,不会因为哈希实现的微小差异变成随机失败,又能抓住“采样率写错一个数量级”的问题。
判据与反例
判断采样与脱敏是否可用,看五条:
| 判据 | 通过标准 | 失败表现 |
|---|---|---|
| 决策稳定 | 同一 key 多次调用结果一致 | 同一会话时断时续 |
| 等级有序 | error、fatal 不采样 | 关键失败被丢弃 |
| 脱敏前置 | 明文不离开设备 | 抓包能看到手机号 |
| 成本可复算 | 字节 × 倍数 × 单价 | 只能看账单猜 |
| 保留可执行 | 到期自动删除且幂等 | 数据永久堆积 |
五个反例:
反例一:用随机数采样。 Math.random() < rate 每次调用都不同,重试会产生不同决策。必须用稳定的 key 做哈希。
反例二:先采样再脱敏。 如果采样 key 本身包含邮箱或手机号,哈希值会泄露可枚举信息。先脱敏,再生成采样 key,或者用 sessionId 这类无业务含义的标识。
反例三:只脱敏邮箱。 手机号、身份证、地址、token 都是 PII。按字段类型建规则,而不是按当前工单里出现的类型补丁。
反例四:把采样率当成放大倍数。 10% 采样不意味着乘以 10 就能还原总量:如果采样本身有偏,放大只会放大偏差。估算可以乘,结论必须注明置信区间。
反例五:保留策略只写在文档里。 没有定时任务、没有删除审计,策略就是一句口号。到期删除要有日志、有幂等、有回滚预案。
小结
采样、脱敏与成本不是三个独立模块,而是一条链:先定等级,再定采样率与保留天数,最后用字节数把成本算出来。
- 采样必须确定性,
error与fatal不参与采样。 - 脱敏按字段类型与键名双轨执行,且不修改入参。
- 成本估算要写清放大倍数、单价口径与压缩口径。
- 保留策略用事件时间,到期删除要幂等。
把这三件事写进同一份配置,团队才能在“看得见”和“付得起”之间做有依据的取舍。
阅读导航:上一节:7.1 日志、指标与追踪的最小契约 · 下一节:7.3 从告警到定位的闭环 。
常见错误与边界
- 采样率按用户而不是按事件。 按用户采样适合分析行为路径,按事件采样适合统计总量。两者混用会让漏斗和总量对不上,必须先声明口径。
- 脱敏后仍保留原字段长度。 用等长掩码或哈希前缀,仍可能被反推。直接整体替换,不保留长度信息。
- 把
sessionId当匿名标识。 如果sessionId能关联到账号,它本身就是准标识符。跨系统共享前要评估可关联性。 - 成本估算忽略属性膨胀。 一条事件的固定字段可能只有 200 字节,属性可以轻松到 2 KB。按 P95 字节数估算,不要用平均值。
- 保留策略一刀切。 所有等级保留 90 天,既浪费存储,又让高价值事件和低价值事件混在一起,删除时无法分级。
- 脱敏规则写死在客户端。 规则需要随合规要求更新,写死在发版代码里会导致旧版本长期漏脱敏。规则应可下发,并有版本号。
- 采样后不做人数校正。 用采样数据算 UV、转化率时,必须按采样率加权,否则会系统性低估。
- 忽略离线补传。 客户端离线缓存后集中补传,会在短时间内形成流量尖峰。补传要有速率限制与单独的采样策略。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。