从一堆堆栈到几个分组
崩溃上报最容易被当成“有堆栈就行”。真实情况是,同一个空指针会在不同设备、不同版本、不同行号上产生成百上千条记录;如果按原始堆栈分组,报表第一天就失去可读性。卡顿更麻烦:它没有异常,只有一段超过帧预算的时间,必须靠样本里的证据判断“谁堵住了主线程”。
本节做两件事:把崩溃收敛成稳定签名,把卡顿样本收敛成可行动的归因。阈值是案例假设值,不是行业标准;真正要固定的是判断顺序和上报字段。
崩溃签名:先归一化,再取前三帧
消息里的随机部分要先抹掉
错误消息常带卡片 ID、行号、UUID 和内存地址。normalizeMessage 先把十六进制地址、UUID 和数字替换成占位符,再压缩空白。这样“card 42 missing”和“card 7 missing”会落到同一个模板,而不是两个分组。
只取最内层三帧
签名取堆栈最内层三帧。更深的帧经常因为构建工具、框架版本或包路径变化而改变,把整条堆栈纳入签名会让同一个问题在每次发版后裂开。代价是不同根因可能被合并,所以聚合结果必须保留 sampleReportId,让人能回到原始堆栈确认。
聚合时保留时间与设备分布
aggregateCrashes 按签名分组,记录次数、占比、首末出现时间、设备档位和版本集合。次数降序、同次数按签名升序,保证同一批数据每次生成的报表顺序一致,方便做前后对比。
卡顿样本与阈值
卡顿样本至少要有窗口时长、主线程阻塞时长、长任务数、强制布局数和路由。只有窗口时长,无法区分“一次长任务”和“十次布局抖动”;只有长任务数,又无法判断主线程到底被占了多少比例。
代码里的默认阈值是:单帧预算 16.7 毫秒(按 60Hz 假设)、严重卡顿 100 毫秒、阻塞占比 0.6、每秒强制布局 4 次。它们都是案例假设值,团队要按目标机型和体验标准调整,但调整后必须把 thresholdsVersion 带进上报事件。
归因顺序:先具体,后笼统
归因顺序固定为布局抖动 > 主线程阻塞 > 长任务。布局抖动证据最具体、最可行动;主线程阻塞看阻塞占比;长任务只看数量,最容易变成一个什么都能装的桶。先判具体原因,能避免所有样本都归到“长任务”而失去行动价值。
超过帧预算但三类证据都不足时,归因结果是 unknown,不是硬塞一个原因。unknown 的比例本身就是一个观测指标:它高,说明采样字段不够,而不是用户运气不好。
代码
下面的文件包含签名、聚合、归因、上报事件与汇总。所有时间由调用方传入,阈值显式作为参数,测试不需要真实设备。
// file: src/obs/crash-jank.ts
// tier: L1
// verify: node --test test/obs/crash-jank.test.ts
export type DeviceClass = "low" | "mid" | "high";
export type JankCause =
| "within-budget"
| "layout-thrash"
| "main-thread-block"
| "long-task"
| "unknown";
export interface StackFrame { module: string; symbol: string; }
export interface CrashReport {
reportId: string;
occurredAtMs: number;
deviceClass: DeviceClass;
appVersion: string;
message: string;
frames: StackFrame[];
}
export interface CrashAggregate {
signature: string;
message: string;
count: number;
share: number;
devices: DeviceClass[];
versions: string[];
firstSeenMs: number;
lastSeenMs: number;
sampleReportId: string;
}
export interface JankSample {
sampleId: string;
occurredAtMs: number;
deviceClass: DeviceClass;
appVersion: string;
route: string;
durationMs: number;
/** 主线程真正被占用的毫秒数,必须不大于 durationMs */
blockingMs: number;
longTaskCount: number;
forcedLayoutCount: number;
}
export interface JankThresholds {
frameBudgetMs: number;
severeMs: number;
blockRatio: number;
layoutsPerSecond: number;
}
export interface JankVerdict { sampleId: string; cause: JankCause; reasons: string[]; }
export interface JankEvent {
event: "client.jank";
schemaVersion: 1;
thresholdsVersion: string;
sampleId: string;
occurredAtMs: number;
deviceClass: DeviceClass;
appVersion: string;
route: string;
cause: JankCause;
durationMs: number;
blockingMs: number;
longTaskCount: number;
forcedLayoutCount: number;
}
export interface JankSummary {
total: number;
janky: number;
causes: Record<JankCause, number>;
p95DurationMs: number;
}
export const DEFAULT_JANK_THRESHOLDS: JankThresholds = {
frameBudgetMs: 16.7,
severeMs: 100,
blockRatio: 0.6,
layoutsPerSecond: 4,
};
/** 把随机 ID、地址和数字替换成占位符,避免同一种崩溃裂成上千个签名。 */
export function normalizeMessage(message: string): string {
return message
.replace(/0x[0-9a-f]+/gi, "#addr")
.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+(\.\d+)?\b/g, "#n")
.replace(/\s+/g, " ")
.trim();
}
function frameKey(frame: StackFrame): string {
return `${frame.module}::${frame.symbol}`;
}
function fnv1a(input: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
/** 签名只取最内层三帧:更深的帧经常随构建变化,会把同一问题拆散。 */
export function crashSignature(report: CrashReport): string {
const framePart = report.frames.slice(0, 3).map(frameKey).join(">");
return fnv1a(`${normalizeMessage(report.message)}|${framePart}`);
}
/** 按签名聚合,次数降序;同次数按签名升序,保证报表顺序稳定。 */
export function aggregateCrashes(reports: readonly CrashReport[]): CrashAggregate[] {
const groups = new Map<string, CrashAggregate>();
for (const report of reports) {
if (report.occurredAtMs < 0 || !Number.isFinite(report.occurredAtMs)) {
throw new Error(`aggregateCrashes: ${report.reportId} 的 occurredAtMs 非法`);
}
const signature = crashSignature(report);
const existing = groups.get(signature);
if (existing === undefined) {
groups.set(signature, {
signature,
message: normalizeMessage(report.message),
count: 1,
share: 0,
devices: [report.deviceClass],
versions: [report.appVersion],
firstSeenMs: report.occurredAtMs,
lastSeenMs: report.occurredAtMs,
sampleReportId: report.reportId,
});
continue;
}
existing.count += 1;
if (!existing.devices.includes(report.deviceClass)) {
existing.devices.push(report.deviceClass);
}
if (!existing.versions.includes(report.appVersion)) {
existing.versions.push(report.appVersion);
}
existing.firstSeenMs = Math.min(existing.firstSeenMs, report.occurredAtMs);
existing.lastSeenMs = Math.max(existing.lastSeenMs, report.occurredAtMs);
}
const total = reports.length;
const aggregates = [...groups.values()];
for (const group of aggregates) {
group.devices.sort();
group.versions.sort();
group.share = total > 0 ? group.count / total : 0;
}
aggregates.sort((left, right) =>
right.count === left.count
? left.signature.localeCompare(right.signature)
: right.count - left.count,
);
return aggregates;
}
function assertJankSample(sample: JankSample): void {
if (!Number.isFinite(sample.durationMs) || sample.durationMs < 0) {
throw new Error(`attributeJank: ${sample.sampleId} 的 durationMs 非法`);
}
if (!Number.isFinite(sample.blockingMs) || sample.blockingMs < 0) {
throw new Error(`attributeJank: ${sample.sampleId} 的 blockingMs 非法`);
}
if (sample.blockingMs > sample.durationMs) {
throw new Error(`attributeJank: ${sample.sampleId} 的 blockingMs 大于 durationMs`);
}
if (sample.longTaskCount < 0 || sample.forcedLayoutCount < 0) {
throw new Error(`attributeJank: ${sample.sampleId} 的计数不能为负`);
}
}
/**
* 归因顺序是布局抖动 > 主线程阻塞 > 长任务。
* 布局抖动最具体、最可行动;主线程阻塞看“占比”,长任务看“数量”。
* 先判具体原因,避免所有样本都落进“长任务”这个无行动价值的桶。
*/
export function attributeJank(
sample: JankSample,
thresholds: JankThresholds = DEFAULT_JANK_THRESHOLDS,
): JankVerdict {
assertJankSample(sample);
if (sample.durationMs <= thresholds.frameBudgetMs) {
return {
sampleId: sample.sampleId,
cause: "within-budget",
reasons: [`时长 ${sample.durationMs}ms 未超过单帧预算 ${thresholds.frameBudgetMs}ms`],
};
}
const reasons: string[] = [];
const seconds = sample.durationMs / 1_000;
const layoutsPerSecond = seconds > 0 ? sample.forcedLayoutCount / seconds : 0;
if (sample.forcedLayoutCount >= 2 && layoutsPerSecond >= thresholds.layoutsPerSecond) {
reasons.push(`每秒强制布局 ${layoutsPerSecond.toFixed(1)} 次,超过阈值 ${thresholds.layoutsPerSecond}`);
return { sampleId: sample.sampleId, cause: "layout-thrash", reasons };
}
const blockRatio = sample.durationMs > 0 ? sample.blockingMs / sample.durationMs : 0;
if (blockRatio >= thresholds.blockRatio && sample.blockingMs >= thresholds.frameBudgetMs * 2) {
reasons.push(
`主线程阻塞占比 ${(blockRatio * 100).toFixed(0)}%,超过阈值 ${(thresholds.blockRatio * 100).toFixed(0)}%`,
);
return { sampleId: sample.sampleId, cause: "main-thread-block", reasons };
}
if (sample.longTaskCount >= 1) {
reasons.push(`窗口内有 ${sample.longTaskCount} 个长任务,但阻塞占比只有 ${(blockRatio * 100).toFixed(0)}%`);
return { sampleId: sample.sampleId, cause: "long-task", reasons };
}
reasons.push("超过帧预算但缺少阻塞、长任务或布局证据,需要补充采样");
return { sampleId: sample.sampleId, cause: "unknown", reasons };
}
/** 上报事件只带聚合结论与原始证据计数,不带用户内容。 */
export function buildJankEvent(
sample: JankSample,
thresholds: JankThresholds = DEFAULT_JANK_THRESHOLDS,
): JankEvent {
const verdict = attributeJank(sample, thresholds);
return {
event: "client.jank",
schemaVersion: 1,
thresholdsVersion: `frame-${thresholds.frameBudgetMs}-block-${thresholds.blockRatio}`,
sampleId: sample.sampleId,
occurredAtMs: sample.occurredAtMs,
deviceClass: sample.deviceClass,
appVersion: sample.appVersion,
route: sample.route,
cause: verdict.cause,
durationMs: sample.durationMs,
blockingMs: sample.blockingMs,
longTaskCount: sample.longTaskCount,
forcedLayoutCount: sample.forcedLayoutCount,
};
}
function p95Of(values: readonly number[]): number {
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.max(1, Math.ceil(0.95 * sorted.length)) - 1] ?? 0;
}
export function summarizeJank(
samples: readonly JankSample[],
thresholds: JankThresholds = DEFAULT_JANK_THRESHOLDS,
): JankSummary {
const causes: Record<JankCause, number> = {
"within-budget": 0,
"layout-thrash": 0,
"main-thread-block": 0,
"long-task": 0,
unknown: 0,
};
for (const sample of samples) {
causes[attributeJank(sample, thresholds).cause] += 1;
}
return {
total: samples.length,
janky: samples.length - causes["within-budget"],
causes,
p95DurationMs: samples.length > 0 ? p95Of(samples.map((sample) => sample.durationMs)) : 0,
};
}
测试与真实输出
在实验工程根目录执行:
node --test test/obs/crash-jank.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 消息归一化把数字、地址与 UUID 替换为占位符 (0.994208ms)
✔ 签名跨不同行号与随机 ID 稳定,不同帧则不同 (0.231083ms)
✔ 崩溃按签名聚合,次数降序并保留首末时间 (0.621875ms)
✔ 单帧预算内的样本不进入卡顿归因 (0.176416ms)
✔ 高频强制布局优先归因为布局抖动 (0.086791ms)
✔ 阻塞占比高归因为主线程阻塞 (0.066125ms)
✔ 阻塞占比不高但存在长任务时归因为长任务 (0.062917ms)
✔ 超过帧预算但缺少证据时标记 unknown (0.059292ms)
✔ 上报事件包含归因结论、证据计数与口径版本 (0.186916ms)
✔ 汇总按原因计数并给出 p95 时长 (0.217542ms)
✔ 非法样本直接抛错,不污染归因结果 (0.267917ms)
ℹ tests 11
ℹ suites 0
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 183.5425
一次被“长任务”掩盖的布局抖动
假设某条卡顿样本:窗口 120 毫秒,阻塞 20 毫秒,长任务 1 个,强制布局 10 次。只看长任务数,很容易写一句“主线程有一个长任务”就结束。但 10 次强制布局发生在 0.12 秒里,折算每秒 83 次,远超阈值,真正的问题是读写样式交替造成的布局抖动。
如果归因顺序反过来,先判长任务,这条样本就会进入一个没有行动价值的桶,修复方向会变成“优化那个长任务”,而不是“合并样式读写”。顺序不是审美问题,它直接决定下一步查哪段代码。
小结
崩溃归因的关键是稳定签名:先归一化消息,再取最内层三帧,聚合时保留样本与时间分布。卡顿归因的关键是证据字段与判断顺序:布局抖动、主线程阻塞、长任务,最后才是 unknown。
阈值可以调,顺序不要随手改。上报事件只带聚合结论和证据计数,不带用户内容,也不把原始堆栈塞进每一条事件。下一节转向另一条链路:埋点规范与数据质量。
阅读导航:上一节:12.1 性能指标口径 · 下一节:12.3 埋点规范与数据质量 。
常见错误与边界
- 按完整堆栈分组。发版后行号变化会让同一问题裂成多个签名。
- 不归一化消息。卡片 ID、UUID 和地址会把分组数量放大几个数量级。
- 只保留聚合结果,不保留样本 ID。合并错了根因时无法回查。
- 用固定毫秒数判断卡顿而不看帧预算。不同刷新率设备的预算不同。
- 把长任务当成唯一原因。布局抖动和阻塞占比会被掩盖。
- 证据不足时硬归因。
unknown是合法结果,比例高说明采样不够。 - 上报事件携带用户内容。堆栈和路由已经足够定位,不要顺手带上参数。
- 改了阈值却不改
thresholdsVersion。新旧数据会被拼成一条曲线。 - 阻塞时长大于窗口时长仍照常入库。这类样本说明计时器有问题,应直接拒绝。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。