事故:告警响了,但不知道是谁在崩
凌晨的告警只看一个总数:崩溃率超过基线的 1.5 倍。值班同学打开看板,整体曲线确实抬头,但无法回答三个问题:是新版本还是旧版本、是低端机还是高端机、是哪个渠道包。等人工按维度导出数据,已经过去两个小时。
案例设定里,《潮汐消消》DAU 80 万、Android 占 60%、4GB RAM 以下低端机占 35%,整体指标很容易被大盘稀释。告警负责「有事」,切片负责「是谁」,灰度负责「停不停」,回归负责「是否真的好了」。 四件事缺一不可。
告警先过统计门槛
定位的第一步不是切片,而是把重复上报压成可比较的事件。同一处崩溃会带不同的内存地址、会话标识与订单号,必须先归一成指纹:地址替换为占位符,UUID 折叠,六位以上的高基数数字替换为 #,保留文件名与行号。指纹相同才合并计数。
合并之后才谈告警。只看「比基线高」会天天误报:样本少时一次崩溃就能让比率翻倍。模型要求三个条件同时满足:分母达到最小样本、点估计达到基线的倍数、Wilson 区间下界仍高于基线。
| 判定 | 条件 | 动作 |
|---|---|---|
| 样本不足 | 分母小于最小样本 | 保持观察,不告警 |
| 未达阈值 | 低于基线倍数,或区间下界覆盖基线 | 不告警 |
| 回归 | 倍数达标且区间下界高于基线 | 告警并进入切片 |
案例设定的崩溃率基线是 0.18%,ANR 率是 0.05%。两者分母都是已启动会话,不能混用;Jank 率的分母是渲染帧,切片时必须切换分母单位,否则 60fps 的方舟会被 30fps 的消消口径带偏。
四维切片:版本、机型、渠道、区域
| 维度 | 常见问题 | 切片价值 | 注意 |
|---|---|---|---|
| 版本 | 新版本引入回归 | 区分新旧包 | 灰度期版本号可能相同,需带构建号 |
| 机型 | 低端机内存或 GPU 兼容 | 找到硬件相关性 | 机型别名要归一,避免同机多名称 |
| 渠道 | 渠道包 SDK 冲突 | 定位分发差异 | 渠道包可能延迟上架,曝光量不同 |
| 区域 | 网络、合规与语言差异 | 定位本地化问题 | 区域要与版本、渠道交叉看 |
切片的分子必须落在同一分母切片内。某个版本有崩溃事件但没有曝光会话,这个事件只能记为「无分母事件」,不能当作零崩溃,也不能塞进其他切片。模型把这类事件单独计数,避免切片结果被污染。
四维全交叉会产生大量小切片,每个都做显著性检验必然出现假阳性。做法是先用一维或二维缩小范围,再对候选切片要求最小样本与置信区间;不要在全量切片里挑一个最高的就宣布根因。
从灰度到回归
灰度不是「发 5% 看看」,而是控制组与实验组的对照。两组都要达到最小会话量,比较崩溃率的区间:实验组区间下界高于「基线加容差」才回滚;点估计在容差内则继续放量;点估计超了但区间仍重叠,保持当前比例继续观察。
| 判定 | 条件 | 动作 |
|---|---|---|
| 继续 | 实验组点估计不超过基线加容差 | 按计划放量 |
| 观察 | 点估计超过,但区间下界未超过阈值 | 暂停放量,累积样本 |
| 回滚 | 区间下界超过基线加容差 | 停止放量并回退 |
修复验证用同一套判据,只是把控制组换成修复前基线、实验组换成修复后版本。验证必须回到最初的切片:整体回到基线不代表低端机切片也恢复。回归用例要固化到自动化里,否则下一次改同一模块还会复发。
案例设定里,方舟的节奏与消消不同:方舟每两周一次版本、每周活动,单场 8–12 分钟,强联网战斗让日志与崩溃上下文更重;消消每周二活动更新,单局 90 秒,样本积累快但版本更频繁。两者共用灰度平台,止损阈值应按各自基线设置,不能直接套用对方的倍数。
可运行模型:event.ts
下面的模型把指纹归一、Wilson 区间、告警判定、四维切片与灰度止损放在一个文件里。它只处理已聚合的计数与维度,不采集设备数据;真实链路中的曝光口径、渠道映射与告警通道需要平台侧接入,这里是模型,不是线上系统。
// file: src/obs/event.ts
// tier: L1
// verify: node --test test/obs/event.test.ts
export type ObsKind = "crash" | "anr" | "jank" | "error";
export interface Dimensions {
readonly version: string;
readonly model: string;
readonly channel: string;
readonly region: string;
}
export type DimensionKey = keyof Dimensions;
export const DIMENSION_KEYS: readonly DimensionKey[] = ["version", "model", "channel", "region"];
export interface ObsEvent {
readonly tsMs: number;
readonly kind: ObsKind;
readonly signature: string;
readonly dims: Dimensions;
readonly weight?: number;
}
export interface Exposure {
readonly dims: Dimensions;
readonly sessions: number;
readonly renderedFrames?: 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 normalizeForFingerprint(message: string, topFrame: string): string {
return `${message} @ ${topFrame}`
.replace(/0x[0-9a-f]+/gi, "0x?")
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
.replace(/\b\d{6,}\b/g, "#")
.replace(/\s+/g, " ")
.trim();
}
export function fingerprint(message: string, topFrame: string): string {
return hash32(normalizeForFingerprint(message, topFrame)).toString(16).padStart(8, "0");
}
export interface Interval {
readonly low: number;
readonly high: number;
}
export function wilsonInterval(successes: number, total: number, z = 1.96): Interval {
if (total <= 0) {
return { low: 0, high: 1 };
}
const p = successes / total;
const z2 = z * z;
const denominator = 1 + z2 / total;
const center = (p + z2 / (2 * total)) / denominator;
const margin = (z * Math.sqrt((p * (1 - p) + z2 / (4 * total)) / total)) / denominator;
return { low: Math.max(0, center - margin), high: Math.min(1, center + margin) };
}
export interface AlertConfig {
readonly baseline: number;
readonly minSessions: number;
readonly minLift: number;
readonly z: number;
}
export const DEFAULT_ALERT_CONFIG: AlertConfig = {
baseline: 0.0018,
minSessions: 2000,
minLift: 1.5,
z: 1.96,
};
export type AlertReason = "ok" | "insufficient-sample" | "below-threshold" | "regression";
export interface AlertDecision {
readonly alert: boolean;
readonly reason: AlertReason;
readonly rate: number;
readonly lift: number;
readonly interval: Interval;
}
export function evaluateAlert(
count: number,
denominator: number,
config: AlertConfig = DEFAULT_ALERT_CONFIG,
): AlertDecision {
if (!Number.isInteger(count) || !Number.isInteger(denominator)) {
throw new RangeError("count and denominator must be integers");
}
if (count < 0 || denominator < 0 || count > denominator) {
throw new RangeError("count must be within [0, denominator]");
}
const interval = wilsonInterval(count, denominator, config.z);
const rate = denominator === 0 ? 0 : count / denominator;
const lift = config.baseline === 0 ? (rate > 0 ? Infinity : 0) : rate / config.baseline;
if (denominator < config.minSessions) return { alert: false, reason: "insufficient-sample", rate, lift, interval };
if (rate < config.baseline * config.minLift || interval.low <= config.baseline) return { alert: false, reason: "below-threshold", rate, lift, interval };
return { alert: true, reason: "regression", rate, lift, interval };
}
export interface SliceStat {
readonly values: Readonly<Record<string, string>>;
readonly count: number;
readonly denominator: number;
readonly rate: number;
readonly lift: number;
readonly decision: AlertDecision;
}
export type DenominatorUnit = "sessions" | "frames";
export interface SliceReport {
readonly unit: DenominatorUnit;
readonly slices: readonly SliceStat[];
readonly orphanEvents: number;
}
export class SliceAnalyzer {
readonly #exposures: Exposure[] = [];
readonly #events: ObsEvent[] = [];
addExposure(exposure: Exposure): void {
if (!Number.isInteger(exposure.sessions) || exposure.sessions < 0) {
throw new RangeError("sessions must be a non-negative integer");
}
this.#exposures.push(exposure);
}
addEvent(event: ObsEvent): void {
const weight = event.weight ?? 1;
if (!Number.isInteger(weight) || weight < 0) {
throw new RangeError("event weight must be a non-negative integer");
}
this.#events.push(event);
}
analyze(
keys: readonly DimensionKey[],
kind: ObsKind,
config: AlertConfig = DEFAULT_ALERT_CONFIG,
): SliceReport {
if (keys.length === 0) {
throw new RangeError("slice needs at least one dimension");
}
const unit: DenominatorUnit = kind === "jank" ? "frames" : "sessions";
const buckets = new Map<string, { values: Record<string, string>; sessions: number; frames: number; count: number }>();
for (const exposure of this.#exposures) {
const values = pick(exposure.dims, keys);
const key = keyOf(values, keys);
const bucket = buckets.get(key) ?? { values, sessions: 0, frames: 0, count: 0 };
bucket.sessions += exposure.sessions;
bucket.frames += exposure.renderedFrames ?? 0;
buckets.set(key, bucket);
}
let orphanEvents = 0;
for (const event of this.#events) {
if (event.kind !== kind) {
continue;
}
const weight = event.weight ?? 1;
const values = pick(event.dims, keys);
const bucket = buckets.get(keyOf(values, keys));
if (!bucket) {
orphanEvents += weight;
continue;
}
bucket.count += weight;
}
const slices: SliceStat[] = [];
for (const bucket of buckets.values()) {
const denominator = unit === "frames" ? bucket.frames : bucket.sessions;
if (denominator <= 0) {
orphanEvents += bucket.count;
continue;
}
const decision = evaluateAlert(bucket.count, denominator, config);
slices.push({
values: bucket.values,
count: bucket.count,
denominator,
rate: decision.rate,
lift: decision.lift,
decision,
});
}
slices.sort((left, right) => right.lift - left.lift || right.count - left.count);
return { unit, slices, orphanEvents };
}
}
function pick(dims: Dimensions, keys: readonly DimensionKey[]): Record<string, string> {
const values: Record<string, string> = {};
for (const key of keys) {
values[key] = dims[key];
}
return values;
}
function keyOf(values: Record<string, string>, keys: readonly DimensionKey[]): string {
return keys.map((key) => values[key]).join("\u0001");
}
export interface GrayArm {
readonly crashes: number;
readonly sessions: number;
}
export interface GrayConfig {
readonly baseline: number;
readonly tolerance: number;
readonly minSessionsPerArm: number;
readonly z: number;
}
export const DEFAULT_GRAY_CONFIG: GrayConfig = {
baseline: 0.0018,
tolerance: 0.0004,
minSessionsPerArm: 5000,
z: 1.96,
};
export type GrayVerdict = "continue" | "hold" | "rollback";
export interface GrayDecision {
readonly verdict: GrayVerdict;
readonly reason: string;
readonly control: { readonly rate: number; readonly interval: Interval };
readonly treatment: { readonly rate: number; readonly interval: Interval };
}
export function evaluateGray(
control: GrayArm,
treatment: GrayArm,
config: GrayConfig = DEFAULT_GRAY_CONFIG,
): GrayDecision {
for (const arm of [control, treatment]) {
if (!Number.isInteger(arm.crashes) || !Number.isInteger(arm.sessions)) {
throw new RangeError("gray arms need integer counts");
}
if (arm.crashes < 0 || arm.sessions < 0 || arm.crashes > arm.sessions) {
throw new RangeError("gray arm crashes must be within [0, sessions]");
}
}
const controlInterval = wilsonInterval(control.crashes, control.sessions, config.z);
const treatmentInterval = wilsonInterval(treatment.crashes, treatment.sessions, config.z);
const controlRate = control.sessions === 0 ? 0 : control.crashes / control.sessions;
const treatmentRate = treatment.sessions === 0 ? 0 : treatment.crashes / treatment.sessions;
const arms = {
control: { rate: controlRate, interval: controlInterval },
treatment: { rate: treatmentRate, interval: treatmentInterval },
};
if (control.sessions < config.minSessionsPerArm || treatment.sessions < config.minSessionsPerArm) return { verdict: "hold", reason: "insufficient-sample", ...arms };
const threshold = config.baseline + config.tolerance;
if (treatmentInterval.low > threshold) return { verdict: "rollback", reason: "treatment-ci-above-threshold", ...arms };
if (treatmentRate <= threshold) return { verdict: "continue", reason: "within-tolerance", ...arms };
return { verdict: "hold", reason: "point-above-but-ci-overlaps", ...arms };
}
与相邻章节的关系
12.1 定义了崩溃、ANR 与 Jank 的分母,本节直接消费这些口径;12.2 的日志脱敏决定切片维度里能保留哪些字段。机型矩阵的验收可以参考兼容性机型矩阵 ,埋点与事件命名可以参考埋点规范 。
小结
- 告警先归一指纹,再比较比率;地址、UUID 与高基数数字不参与分组。
- 样本不足、未达倍数、区间覆盖基线都不应告警;统计门槛比阈值本身更重要。
- 切片按版本、机型、渠道、区域逐层缩小,分子必须落在同一分母内,无分母事件单独计数。
- 灰度用控制组与实验组的区间对照,继续、观察、回滚三态比「发不发」更可执行。
- 修复验证回到最初的切片,并把回归用例固化;整体恢复不代表问题真的关闭。
阅读导航:上一节:12.2 日志分级、采样与隐私脱敏 · 下一节:13.1 模块矩阵与依赖报告 。
常见错误与边界
- 看到整体上涨就回滚。整体可能被大盘稀释或放大,先切片再决定;否则会误伤正常版本。
- 不做指纹归一。同一崩溃因地址或订单号不同被拆成上千条,告警噪声掩盖真实回归。
- 样本不足就宣布根因。几次崩溃在低流量切片里就能翻倍,必须要求最小分母与置信区间。
- 分子分母不匹配。Jank 用会话作分母、崩溃用帧作分母都会得到无意义的结果。
- 在全量切片里挑最高值。多维交叉会制造假阳性,应先降维再检验候选切片。
- 灰度只看点估计。区间仍重叠时贸然回滚会错过真实有效的版本,应保持比例继续观察。
- 修复后只看整体。最初出问题的机型或渠道切片没有恢复,就不能关闭问题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。