Why Your RUM Data Is Wrong: The 6 Traps of Performance Monitoring
Performance Monitoring
1. Technical Difficulties
Previous 33 articles covered "how to make it faster": rendering pipeline, loading, network, SSR, runtime, bundle, images, fonts, Worker, WASM. This article covers the last piece, also the one most easily turned into fake data: how do you know you really got faster, and how do you know you're not fooling yourself.
This article uses no third-party libraries: vitals.mjs (metric-pure functions) + sdk.mjs (collection/buffering/reporting) + harness.mjs (deterministic fake-page replay) + bench.mjs (source of all numbers) + test-rum.mjs (56 assertions all green) + extract-and-test.mjs (extracts code blocks from the text and runs them directly). Environment: Node 22.23.3 / darwin-arm64.
Here are the conclusions first. Each line below was measured on this machine, not extrapolated:
| Common Belief | Measured Result | Where It Differs |
|---|---|---|
| "CLS is the sum of layout shifts" | In 500 simulated sessions, naive sum raises p75 from 0.1634 to 0.4376 (2.68x), sessions rated "good (≤0.1)" drop from 26.2% to 2.2% | CLS is the maximum within a session window; windows are split by 1s gap / 5s cap |
| "Client-side sampling and mixing percentiles is fine" | 1% mobile + 10% desktop batches, p75 off by −30.8% (1659.7 vs true 2398.7ms); weighted −2.9% | Sampling bias is a systematic shift; whoever samples more dominates |
| "Compute p90 per shard, then average" | 8 shards: true 2220.5ms, averaging 2192.1ms (−1.28%), histogram sum 2225.0ms (+0.20%) | Quantiles have no inverse operation; histograms can be summed |
| "Reservoir sampling is the simplest" | n=100 p75 average error 6.89%, p95 10.01%; n=2000 drops to 1.05% / 2.09% | Storage grows 20x for a few digits of precision; 76 histogram buckets are more accurate |
| "INP is the worst interaction" | 200 interactions: 1~4 spikes of 480ms are completely trimmed away; only the 5th shows; at 1000 interactions difference is 79.5ms | INP is the trimmed typical worst, not historical worst |
| "Collection overhead is negligible" | mark+measure 0.22~0.38 µs/op; with observer difference −0.04~+0.12 µs (crosses zero = within noise); push object 0.0037 µs/op. But JSON.stringify(10k) ≈ 2.6ms, already a long task |
Expensive parts are serialization and per-frame flush, not collection |
"sendBeacon is convenient" |
500 entries with attribution = 83513B > 64KB, browser silently drops, no console error | Attribution fields raise single entry from ~120B to ~300B; budget must be computed first |
Difficulty 1: CLS is not a sum, it's the maximum of a session window
Most self-built SDK CLS implementations look like: s += entry.value. That is catastrophic on real pages.
layout-shift semantics: "what fraction of viewport moved in this frame". A page from load to close might have 30 shifts: skeleton replacement (0.12), font load line-height change (0.03), three images expanding (0.05×3), recommendation insert (0.08). Sum is 0.38, but what the user actually "feels pushed" is only the most concentrated segment.
Chrome's spec uses session windows: shifts are clustered by time, gap > 1s breaks, window max 5s, CLS = max across windows. Also:
hadRecentInput === trueshifts are discarded entirely (user had input within 500ms → user-caused, not "unexpected layout shift");- shifts during hidden page are not counted (otherwise background-tab carousels, ads, skeleton animations would inflate CLS).
Measured (500 simulated sessions, bench.mjs §1):
Naive sum p50=0.3348 p75=0.4376 p95=0.6030
Session win p50=0.1250 p75=0.1634 p95=0.2567
→ p75 inflated 2.68x by naive sum (CWV threshold 0.1 flips from "good" to "needs improvement")
→ sessions rated "good(<=0.1)": naive 2.2% vs correct 26.2%
That is: with the wrong interpretation, you mark 26.2% of qualified pages as unqualified, then spend three months optimizing a problem that doesn't exist.
Difficulty 2: Percentiles are not invertible, and all performance metrics are percentiles
Industry standard is p75 (Core Web Vitals uses 75th percentile). So everyone faces: how to combine N machines / N shards / N sampling batches into one p75?
A percentile is a lossy aggregate. I know 8 machines' p90s are 1850, 1927, 2021, 2123, 2241, 2350, 2459, 2566; these 8 numbers cannot infer the overall p90 because each loses its distribution shape and sample size. Measured (§4):
True p90 = 2220.5ms
Average of 8 shard p90 scalars = 2192.1ms → error −1.28%
Sum of 8 shard histograms → p90 = 2225.0ms → error +0.20%
−1.28% looks "acceptable", but that's the optimistic case of equal sample sizes and similar distributions. In reality, an edge datacenter might have only 300 samples, the main DC 300k; averaging will inflate edge p90 into overall p90—a single DC outage becomes a cluster-wide degradation you may not even see.
The correct container is a histogram: it's not a "statistical result" but a "compressed representation of the distribution", so buckets can be summed (§4 8 shards with 50ms buckets sum to 98 buckets, error 0.20%).
Difficulty 3: Sampling bias is not noise, it's systematic offset
More hidden than histograms is inconsistent sample rates. Real scenario: mobile traffic is 80%, but to save money you sample mobile at 1%, desktop at 10%. Reported 1700 entries: mobile 500, desktop 1200—desktop is 70% of reported data (true 20%). Measured (§3):
Mobile sample 500 (true 50000, weight 100)
Desktop sample 1200 (true 12000, weight 10)
Overall true p75 = 2398.7ms
Direct mix p75 = 1659.7ms (error −30.8%)
Weighted merge p75 = 2329.5ms (error −2.9%)
−30.8%. You'll see "mobile performance greatly improved" on your report while nothing changed—you just sampled fewer slow ones.
Three practical corollaries:
- Sampling must carry weight:
weight = 1 / sampleRate, store with value, weight when aggregating (histograms naturally add: buckets hold weighted counts). - Sample once at session level, not per metric. Otherwise you get "CLS but no LCP" broken sessions that can't be attributed (
sdk.mjsdeterminessampledat construction). - Sample rate must be high enough.
§10tested 50k sessions, repeated 40 times:
| Sampling Rate | Sample Size | p75 Estimate | Relative Fluctuation |
|---|---|---|---|
| 1% | ≈500 | 1904.7ms | ±57.6ms (3.03%) |
| 5% | ≈2500 | 1908.5ms | ±25.2ms (1.32%) |
| 20% | ≈10000 | 1907.2ms | ±12.9ms (0.68%) |
1% sampling already estimates p75 well (true 1909.7ms, deviation 0.3%). Cutting 3% fluctuation to 1% costs 5x collection—classic "effort in the wrong place."
Difficulty 4: Each metric definition is a state machine, not a formula
This is the most easily skipped and most likely to cause total failure. Three metrics each have a state machine:
LCP: candidate keeps being replaced until "first input" freezes it. LCP is not "the largest image", but the largest content element that appears before the first user interaction. Once the user clicks/scrolls/types, LCP freezes—because subsequent loading is user-driven. Measured (§2):
Candidates: 900ms/20000, 1400ms/90000, 5000ms/400000
No input → LCP = 5000ms (max size)
First input 3300 → LCP = 1400ms
First input 1000 → LCP = 900ms
Same entry stream, LCP can be 900, 1400, or 5000—5.5x difference. If your SDK uses "last candidate" or "max candidate" as LCP, you'll never match Search Console data, and you won't know who's right.
INP: worst interaction, but trimmed. INP is also not "the slowest click". Chrome's algorithm: sort interactions by latency, for every 50 interactions drop 1 highest (equivalent to 98th percentile); if ≤50 interactions, no trim. Measured (§8, 200 interactions, each 60ms plus some 480ms spikes):
| Spike Count | Max before trim | INP after trim |
|---|---|---|
| 1 | 480 | 60 (erased) |
| 4 | 480 | 60 (trim exactly 4) |
| 5 | 480 | 480 (5th shows) |
This trade-off seems to "hide problems", but it's deliberate: pages with thousands of interactions inevitably have occasional spikes (GC, tab switch, OS preemption). INP aims for "typical worst", not "historical worst"—only the former is optimizable, reproducible, shippable. Conversely, if one spike ruins your metric, your sample size is too small; fix interaction instrumentation coverage first, not chase that spike.
TBT: only counts long tasks before TTI. TBT = Σ(max(0, long task duration − 50ms)), and only up to interactivity. Same 600ms total but different shapes give completely different TBT and feel (§9):
| Shape | Total | TBT | Worst single block |
|---|---|---|---|
| 3x200ms | 600 | 450ms | 150ms |
| 10x60ms | 600 | 100ms | 10ms |
| 1x600ms | 600 | 550ms | 550ms |
TBT is "total ledger", worstBlocking is "feel". That's why after adopting LoAF (long-animation-frame) you collect both blockingDuration and scripts attribution: 50ms×10 is tolerable, one 550ms white screen sends users away.
Difficulty 5: Observer effect—the monitoring system itself is a performance problem
Three measured/hard constraints:
- Collection is cheap, serialization is expensive.
mark+measure0.22~0.38 µs/op (3 rounds × 5 min, jitter ±30%); with observer difference −0.04~+0.12 µs/op—crosses zero, callback cost within noise (honest conclusion: callback isn't a bottleneck; don't use it as an excuse to cut instrumentation). Direct push object to array 0.0037 µs/op, 60-90x cheaper than measure. ButJSON.stringify1000 entries ≈255 µs, 10k ≈2.6ms—the former is "zero cost", the latter is itself a long task (§5,§6). - Uncleaned entry buffers keep growing. In
§5, just 5000 mark/measure calls left 5000 entries ingetEntriesByType('measure'). WithoutclearMeasures(),getEntriesgets slower and memory never returns—monitoring turns itself into a memory-leak source. sendBeaconover 64KB is silently dropped.§7: plain JSON 500 entries with attribution = 83513B, 1000 = 167013B, both over—browser doesn't throw, doesn't retry, console is silent. You think you're reporting, actually discarding. Columnar encoding compresses 500 to 64581B (just under limit), but 1000 is 129081B again—so the budget must be in bytes, not entries.
Difficulty 6: Field data and lab data always conflict
This contradiction has no technical solution, only clarity about what each answers:
| Lab (Lighthouse/CI) | Field (RUM) | |
|---|---|---|
| Sample | 1 machine, fixed network, cold cache | Real users, real devices/network/region |
| Answers | "Can it be faster" (reproducible, attributable) | "Is it faster" (credible, includes long tail) |
| Typical bias | Optimistic (local M2 + gigabit) | Pessimistic (locked 4 cores + weak network + background tab) |
| Use | Gate thresholds, prevent regression | Prioritize, verify gains |
Most common mistake: using lab data to judge gains. You remove 40KB JS, Lighthouse 78→92, but real user p75 may not move at all—because their bottleneck is 4G first package and low-end main thread, not that 40KB. Conversely, RUM can only say "it's slow", not "where".
Correct approach: use RUM to identify populations and pages, lab to reproduce and attribute, and bridge both with the same entry stream (section 2.1).
2. Complete Solution
2.1 Architecture foundation: write metrics as pure functions (entry stream → values)
Everything in this article rests on one design principle:
Browser glue only feeds entries; metric calculation is all pure functions.
Three benefits, none optional:
- Can assert in Node—no browser needed;
test-rum.mjs56 assertions run in seconds. - Can replay real production logs—user reports "CLS high on this page", export that entry stream, run locally to see which window, which shifts.
- Lab and field share same interpretation—otherwise you'll forever explain "why the two reports differ".
// cls-window.mjs —— 会话窗口:分组靠"间隔",取值靠"最大窗口"
import { clsSessionWindow, clsNaiveSum } from './vitals.mjs';
const shifts = [
{ value: 0.12, startTime: 1800 }, // 骨架屏 → 内容
{ value: 0.05, startTime: 3200, hadRecentInput: true }, // 点击后插入:丢弃
{ value: 0.08, startTime: 4200 }, // 图片撑开
{ value: 0.06, startTime: 4600 }, // 同批渲染,间隔 400ms → 同窗口
];
const w = clsSessionWindow(shifts);
console.log('窗口:', JSON.stringify(w.windows));
console.log('CLS =', w.cls, '| 朴素求和 =', clsNaiveSum(shifts));
// 断言:口径错了会静默给出错误答案,只能靠断言锁住
if (w.windows.length !== 2) throw new Error(`应切出 2 个窗口,实得 ${w.windows.length}`);
if (w.cls !== 0.14) throw new Error(`CLS 应为 0.14(max 窗口),实得 ${w.cls}`);
if (clsNaiveSum(shifts) !== 0.26) throw new Error(`朴素求和应为 0.26,实得 ${clsNaiveSum(shifts)}`);
Running it gives CLS = 0.14 | 朴素求和 = 0.26—1.86x difference just because 1800ms and 4200ms are 2.4s apart. And the hadRecentInput shift of 0.05 is entirely excluded.
2.2 LCP freeze and INP trimming
// lcp-inp.mjs —— 两个状态机
import { lcpFromCandidates, inpFromInteractions, inpNaiveMax, interactionLatency } from './vitals.mjs';
const candidates = [
{ startTime: 900, size: 20000, element: 'h1' },
{ startTime: 1400, size: 90000, element: 'img.hero' },
{ startTime: 5000, size: 400000, element: 'img.lazy' }, // 首屏之后才加载的大图
];
// LCP:首次输入即冻结,之后的大图不算
const frozen = lcpFromCandidates(candidates, { firstInputTime: 3300 });
const noInput = lcpFromCandidates(candidates, { firstInputTime: Infinity });
console.log('首次输入 3300 → LCP', frozen.value, 'ms | 无输入 →', noInput.value, 'ms');
if (frozen.value !== 1400) throw new Error(`冻结后应为 1400,实得 ${frozen.value}`);
if (noInput.value !== 5000) throw new Error(`无输入应为 5000,实得 ${noInput.value}`);
// INP:截尾 2%,丢掉"偶发尖刺"但保留系统性慢
const mk = (spikes) =>
Array.from({ length: 200 }, (_, i) => ({ latency: i >= 200 - spikes ? 480 : 60, name: 'pointerdown' }));
for (const n of [1, 4, 5]) {
console.log(`200 次交互 / ${n} 个尖刺 → INP ${inpFromInteractions(mk(n)).value}ms(max ${inpNaiveMax(mk(n))}ms)`);
}
if (inpFromInteractions(mk(4)).value !== 60) throw new Error('4 个尖刺应被截尾抹掉');
if (inpFromInteractions(mk(5)).value !== 480) throw new Error('第 5 个尖刺应露头');
// 单次交互 latency:500ms 上限 + 脏数据守卫
if (interactionLatency(100, 800) !== 500) throw new Error('交互 latency 应被 500ms 截断');
if (interactionLatency(100, 90) !== 0) throw new Error('processingEnd 早于 start 应返回 0');
console.log('OK');
2.3 The correct container for percentiles: weighted histograms
// histogram.mjs —— 直方图是唯一"可加"的分位容器
import {
histogramFixed, histogramPercentile, mergeHistograms,
exactPercentile, weightedPercentileValue, lognormal, mulberry32,
} from './vitals.mjs';
const rand = mulberry32(2026);
// 造两批"采样率不同"的真实数据:移动端慢(中位 ~1800ms)但采样稀
const mobile = Array.from({ length: 6000 }, () => lognormal(rand, 7.5, 0.5)).filter((_, i) => i % 10 === 0);
const desktop = Array.from({ length: 6000 }, () => lognormal(rand, 6.9, 0.4)).filter((_, i) => i % 2 === 0);
// ✅ 直方图:每片自己维护,服务端按桶相加(weight 体现采样率)
const hists = [
{ hist: histogramFixed(mobile, 50), weight: 10 },
{ hist: histogramFixed(desktop, 50), weight: 3 },
];
const merged = mergeHistograms(hists.map((h) => h.hist));
console.log(`桶数 ${merged.buckets.size}, 样本 ${merged.total}, 合并 p75 = ${histogramPercentile(merged, 0.75).toFixed(0)}ms`);
// ✅ 加权分位:必须带 weight,否则谁的采样率高谁主导
const weighted = weightedPercentileValue(
[{ values: mobile, weight: 10 }, { values: desktop, weight: 3 }],
0.75,
);
const naive = exactPercentile([...mobile, ...desktop], 0.75);
console.log(`加权 p75 = ${weighted.toFixed(0)}ms | 不加权混算 = ${naive.toFixed(0)}ms`);
if (Math.abs(weighted - naive) < 1) throw new Error('两批分布差异明显,加权与不加权不应相等');
if (merged.total !== mobile.length + desktop.length) throw new Error('直方图相加丢样本');
console.log('OK');
Key points:
- Bucket width is the only knob for precision vs storage.
§2measured (n=20000, LCP long tail): 50ms buckets need 138 buckets, p75 error −0.30%; 100ms buckets need 76 buckets, error rises to −1.70% (5.7x). For wide value ranges (50ms to 30s) use log buckets: 10/decade only 20 buckets, but p50/p95 errors −8.77% / −6.83%; at 20/decade, 38 buckets, errors improve to −3.37% / −1.30%. - Don't use reservoir sampling. n=100 p75 average error 6.89%, p95 10.01%; n=2000 drops to 1.05% / 2.09%—storage grows 20x, while the 50ms bucket above needed only 138 buckets for 0.3% error. Reservoir's only advantage is "can recover raw values"; if attribution needs specific samples, use it; otherwise use histogram.
- Never mix bucket widths in the same table. Adding 50ms and 100ms buckets is a silent disaster (no numeric error, all results skewed).
2.4 Collection layer: buffer and "who to drop"
The core of collection is not "how to collect" but what to do when full, and how to handle dirty data. Three rules:
- Never take dirty data:
NaN, negative values are dropped (!(value >= 0)also guardsNaN). OneNaNin a histogram makes the entire percentile NaN, and aggregation services usually just return empty without error—you'll discover "no data for this metric" two weeks later, without knowing from which day. - When buffer is full, drop details first, never core metrics.
sdk.mjsevictsdbg.*prefixed details first; if the buffer contains only core metrics, prefer dropping new points over already-computed core (a computed point represents a complete session's conclusion; a new point might be a duplicate from the same session). - Session-level sampling is decided once (see Difficulty 3).
// buffer-guard.mjs —— 缓冲区的丢弃策略与脏数据守卫
import { createSession } from './harness.mjs';
const { rum } = createSession({ maxBuffer: 4 });
// 先塞满明细
for (let i = 0; i < 3; i++) rum.push('dbg.resource', i, i);
// 再塞核心指标
rum.push('cls', 0.12, 10);
rum.push('lcp', 900, 11);
// 脏数据
rum.push('cls', NaN, 12); // 丢弃
rum.push('lcp', -1, 13); // 丢弃
const names = rum.buffer.map((b) => b.name);
console.log('缓冲区:', JSON.stringify(names), '| 丢弃数:', rum.dropped);
if (rum.buffer.length !== 4) throw new Error(`缓冲区应满 4 条,实得 ${rum.buffer.length}`);
if (!names.includes('cls') || !names.includes('lcp')) throw new Error('核心指标被丢了');
if (names.filter((n) => n === 'dbg.resource').length !== 2) throw new Error('应挤掉 1 条明细');
if (rum.buffer.some((b) => !(b.value >= 0))) throw new Error('脏数据进了缓冲区');
console.log('OK');
2.5 Reporting: compression, budget, timing
Compression: columnar encoding (field names not repeated) reduces 1000 details from 72480B to 40523B (55.9%); after gzip 15204B → 6720B, still 55.8% savings (§6). That gzip still has gains shows: the main saving isn't repeated field names, but structural redundancy per entry (object braces, quotes, float digits).
Budget: before each flush compute bytes; if over 64KB, split batches (sdk.mjs uses BEACON_LIMIT_BYTES - 1024 for margin). Attribution fields are the main culprit for size: one entry grows from ~120B to ~300B; plain JSON 500 entries already overflow; columnar can hold 500 just barely, 1000 overflows again. Split by bytes, not by entries.
Pitfall we actually hit: the first version of encodeReport only encoded name/value/ts three columns, and the stress-test report looked great—because attribution fields were silently dropped as a whole column. The failure mode of columnar encoding is not "becomes bigger" but "loses something without error". Now vitals.mjs detects any entry with el/url/ev and fills the entire column, and uses decodeReport round-trip assertions (test-rum.mjs §9, 5 tests).
Capacity: a single "4 metrics + attribution" packet is actually only 105B (gzip 98B). 1 million PV × 2 flushes ≈ 210MB/day upload—the storage and bandwidth cost of a monitoring system is almost always inflated by detail fields, not core metrics. That's the economic justification for "attribution only on anomalous sessions".
Timing: the only trustworthy "last moment" is visibilitychange → hidden.
unload/beforeunloadare frequently not triggered on mobile, bfcache, iOS;- Now browsers enable bfcache by default; when the user "goes back", the page never goes through unload;
pagehidewithevent.persisted === trueis the correct signal; - Don't skip periodic flush: long-staying pages (admin panels, editors) might be hidden only after hours; a crash in between loses everything.
// flush-size.mjs —— 上报前必须按字节算预算,不能按条数猜
import { encodeReport, BEACON_LIMIT_BYTES } from './vitals.mjs';
const mkReport = (n, withAttr) =>
Array.from({ length: n }, (_, i) => ({
name: 'lcp',
value: 1234.5 + i,
ts: 1_700_000_000_000 + i,
...(withAttr
? { el: 'main > div:nth-child(3) > img.hero-img', url: '/static/media/hero.9f3a2b.webp', ev: 'pointerdown' }
: {}),
}));
for (const n of [200, 500]) {
const slim = Buffer.byteLength(encodeReport(mkReport(n, false)));
const attr = Buffer.byteLength(encodeReport(mkReport(n, true)));
const plainAttr = Buffer.byteLength(JSON.stringify({ reports: mkReport(n, true) }));
console.log(
`${String(n).padStart(4)} 条 | 列式仅指标 ${String(slim).padStart(6)}B | 列式带归因 ${String(attr).padStart(6)}B | ` +
`朴素带归因 ${String(plainAttr).padStart(6)}B ${plainAttr > BEACON_LIMIT_BYTES ? '❌ 静默丢' : '✅'}`,
);
if (n === 500 && plainAttr <= BEACON_LIMIT_BYTES) throw new Error('朴素 JSON 500 条归因明细应超 64KB');
if (n === 500 && attr > BEACON_LIMIT_BYTES) throw new Error('列式 500 条应仍在上限内');
}
// 切批:按实际字节切,保证每包都留 1KB 余量
const rows = mkReport(1000, true);
const chunks = [];
let cur = [];
for (const r of rows) {
const trial = [...cur, r];
if (Buffer.byteLength(encodeReport(trial)) > BEACON_LIMIT_BYTES - 1024) { chunks.push(cur); cur = [r]; }
else cur = trial;
}
if (cur.length) chunks.push(cur);
const sizes = chunks.map((c) => Buffer.byteLength(encodeReport(c)));
console.log(`1000 条切成 ${chunks.length} 包,单包 ${sizes.join('/')}B,最大 ${Math.max(...sizes)}B`);
if (Math.max(...sizes) > BEACON_LIMIT_BYTES - 1024) throw new Error('仍有分包超预算');
console.log('OK');
2.6 Glue layer: isomorphic, replayable
This is the only code that "touches the browser", so it only does three things: register observers, convert entries to plain data, attach lifecycle. It contains no computation—so it can run in Node with a fake window.
// rum-install.mjs —— 浏览器里接 PerformanceObserver,Node 里接假窗口,同一份胶水代码
import { Rum } from './sdk.mjs';
import { typicalPage } from './harness.mjs';
export function installRum(win, opts) {
const rum = new Rum({ endpoint: opts.endpoint, sampleRate: opts.sampleRate ?? 1, transport: opts.transport });
const PO = win.PerformanceObserver;
new PO((l) => { for (const e of l.getEntries()) if (!e.hadRecentInput) rum.onLayoutShift({ value: e.value }, e.startTime); })
.observe({ type: 'layout-shift', buffered: true });
new PO((l) => { for (const e of l.getEntries()) rum.onLcpCandidate({ size: e.size, element: e.element?.tagName, url: e.url }, e.startTime); })
.observe({ type: 'largest-contentful-paint', buffered: true });
// durationThreshold 别低于 16ms:事件量会爆炸,而 16ms 以下对 INP 没有意义
new PO((l) => { for (const e of l.getEntries()) { rum.onEvent(e); rum.onInteraction(e); } })
.observe({ type: 'event', buffered: true, durationThreshold: 16 });
new PO((l) => { for (const e of l.getEntries()) rum.onLongTask(e); })
.observe({ type: 'longtask', buffered: true });
win.addEventListener('visibilitychange', () => {
if (win.document.visibilityState === 'hidden') rum.onHidden(win.performance.now());
});
// bfcache 往返恢复:不补这一发,这段会话永远不上报
win.addEventListener('pagehide', (e) => { if (e.persisted) rum.onHidden(win.performance.now()); });
return rum;
}
/* ---- Node 侧:假窗口,连 "buffered: true 会补发历史 entry" 都模拟 ---- */
function fakeWindow(entries, nowMs) {
const buffer = { 'layout-shift': [], 'largest-contentful-paint': [], event: [], longtask: [] };
for (const s of entries.shifts) buffer['layout-shift'].push({ value: s.value, startTime: s.startTime, hadRecentInput: !!s.hadRecentInput });
for (const c of entries.lcp) buffer['largest-contentful-paint'].push(c);
for (const i of entries.interactions) {
buffer.event.push({ startTime: i.startTime, processingEnd: i.processingEnd, interactionId: i.interactionId, name: i.name });
}
for (const t of entries.longTasks) buffer.longtask.push(t);
const live = [];
class FakePO {
constructor(cb) { this.cb = cb; }
observe({ type, buffered }) {
const self = this;
live.push({ type, deliver: (e) => self.cb({ getEntries: () => [e] }) });
if (buffered) for (const e of buffer[type] ?? []) self.cb({ getEntries: () => [e] });
}
disconnect() {}
takeRecords() { return []; }
}
const win = {
PerformanceObserver: FakePO,
performance: { now: () => nowMs },
document: { visibilityState: 'visible' },
listeners: {},
addEventListener(t, fn) { (this.listeners[t] ??= []).push(fn); },
};
// 安装后再来的 entry 走 live 回调
const emit = (type, e) => live.filter((x) => x.type === type).forEach((x) => x.deliver(e));
const hide = () => {
win.document.visibilityState = 'hidden';
(win.listeners.visibilitychange ?? []).forEach((f) => f());
};
return { win, emit, hide };
}
const sent = [];
const { win, hide } = fakeWindow(typicalPage(), 9000);
const rum = installRum(win, { endpoint: 'https://r.example.com/collect', transport: (u, p) => sent.push(p) });
hide(); // 唯一的 flush 时机
const report = JSON.parse(sent[0]);
const get = (n) => report.c.v[report.c.n.indexOf(n)];
console.log('上报:', JSON.stringify(report.c));
if (get('cls') !== 0.12) throw new Error(`CLS 应为 0.12(最大窗口),实得 ${get('cls')}`);
if (get('lcp') !== 1400) throw new Error(`LCP 应为 1400(首次输入已冻结),实得 ${get('lcp')}`);
if (get('inp') !== 200) throw new Error(`INP 应为 200,实得 ${get('inp')}`);
if (get('tbt') !== 200) throw new Error(`TBT 应为 200,实得 ${get('tbt')}`);
if (sent.length !== 1) throw new Error('hidden 时应且仅当发一包');
console.log('OK');
2.7 Acceptance: turn interpretations into assertions
test-rum.mjs's 56 assertions cover: three types of session-window splitting (gap break, 5s cap, input filter), three LCP freeze timings, INP trimming boundaries (50/51/100/1000), TBT TTI cutoff, histogram additivity and error bounds, weighted vs unweighted, zero reporting when sample=0, buffer eviction priority, dirty-data guards, hidden-period filtering, columnar encoding size. Run it:
node test-rum.mjs # 56 通过 / 0 失败
node bench.mjs # 生成 bench-log.txt(本文所有数字)
node extract-and-test.mjs # 抽正文代码块,落地再跑
Any interpretation that's broken will light up red immediately. This is the difference between "performance monitoring" and "performance dashboard": the latter just draws numbers; the former can prove the numbers are right.
2.8 Three engineering conventions (more important than code)
- Metrics need an owner and a budget. Every metric must answer "what's good, who's responsible if over, what to do if over". A metric without a budget becomes a wallpaper that "stays red forever, nobody handles it".
- Dashboard defaults to p75 + histogram shape, never just an average. Averages hide bimodality (half users 300ms, half 5000ms, mean 2650ms—looks "medium", but both camps are unhappy).
- Any optimization must ship with a before/after RUM comparison and state "which metric is expected to move, by how much". Without an expectation, you can't judge success afterward (this is also the methodology basis for Day56 performance case study).
3. Application Scenarios
3.1 Decision Table
| Scenario | What to Do | Key Numbers (from this article) | What Not to Do |
|---|---|---|---|
| C-end first screen (e-commerce/content) | Collect all four CWV metrics + bucket by route/device/network | CLS wrong interpretation → p75 off 2.68x | Report client-computed averages |
| Admin/B-end SaaS | Focus INP + long tasks, ignore LCP (usually warm startup) | 200-interaction trim removes 4 spikes | Compute INP with tiny sample (<50 interactions) |
| Multi-DC/edge | Histogram bucket-sum, global bucket width | Shard p90 averaging error −1.28%, histogram +0.20% | Aggregate per-shard percentiles first |
| Self-built SDK/component library | Report only "overhead introduced by your component" | mark+measure 0.22~0.38 µs/op | Collect all site metrics (you're not a monitoring platform) |
| Weak network/mobile | Lower sample rate but must carry weight | Mixing off −30.8%; 1% sampling p75 fluctuation 3.03% | Sample only slow users (heavy selection bias) |
| Needs attribution | Only collect attribution on anomalous sessions | 500 attributions = 83513B > 64KB | Always include selectors/URLs |
3.2 Scenario 1: A real debugging flow for "metrics don't match"
Symptom: self-built dashboard LCP p75 = 2.4s, Search Console shows 3.9s, 1.5s gap.
Debug order (each step has tool support in this article):
- First align interpretation: Does our LCP freeze after first input? No—we use "max candidate", so a large image that loads after scroll counts → this would make our number larger, but actually it's smaller; something else is at play.
- Then check sampling: We down-sampled first-screen-slow devices ("save traffic"), accidentally under-sampling slow samples → systematic under-report (Difficulty 3, −30.8% magnitude).
- Then check timing: Only report on
unload, and 40% of sessions (mobile + bfcache) never get there → missing exactly the "early-leaving slow sessions". - Export real entry stream and replay: Use
harness.mjsto replay, confirming 3.9s is the correct number.
Four steps, three are interpretation/engineering bugs; none is "the page actually got faster".
3.3 Scenario 2: Bridge lab and field
CI runs Lighthouse to get lab data; online has RUM. To bridge:
- Both sides use
vitals.mjs(pure functions); lab side usesharness.mjsto feed Puppeteer-captured entry streams into the same computation; - Lab gates thresholds (LCP < 2.5s or fail) because reproducible, no long-tail noise;
- Field prioritizes: which route, device, network bucket has worst p75, and whether histogram is bimodal or long-tail;
- Gain validation uses field only, with same-population before/after (same route, device, network) to avoid "overall rose but traffic structure changed".
3.4 Scenario 3: The "invisible denominator" of low-end / weak-network users
RUM is easiest to deceive here: reporting itself needs network and CPU. On weak network + laggy devices, SDK flush fails more easily, gets killed more, reaches hidden less. So "reported data" naturally favors good devices—the users you most need to see, you see least.
Countermeasures (all low-cost):
- Dual channel
visibilitychange+ periodic flush to shorten "unreported window"; - Failed sessions get a sampling marker, not dropped, combined with a server-side "missing rate" metric (missing rate itself is a core metric, watch with p75);
- Use
navigator.sendBeacon+keepalive: truefetch dual-write; the latter has receipt for 4xx; - Look at p75 per device/network bucket, not just overall—overall p75 is always pulled to the middle by mainstream devices.
3.5 Scenario 4: CI gate for performance budgets
// budget.mjs —— 卡点写法:把预算写成断言,而不是文档里的一句话
const budgets = { lcp: 2500, cls: 0.1, inp: 200, tbt: 300 };
const runs = [
{ name: '本次 PR', measured: { lcp: 2300, cls: 0.08, inp: 190, tbt: 280 } },
{ name: '反例(上一版)', measured: { lcp: 2600, cls: 0.12, inp: 210, tbt: 320 } },
];
let bad = 0;
for (const r of runs) {
const fails = Object.entries(budgets)
.filter(([k, v]) => r.measured[k] > v)
.map(([k, v]) => `${k}: ${r.measured[k]} > ${v}`);
if (fails.length) { bad += 1; console.log(`❌ ${r.name} 超标: ${fails.join(', ')}`); }
else console.log(`✅ ${r.name} 通过`);
}
// CI 里换成 process.exit(bad ? 1 : 0)
if (bad !== 1) throw new Error(`应有 1 次超标,实得 ${bad}`);
This can go directly into the PR pipeline (Day38's topic). Key: budget uses p75 + small tolerance, and must be tied to "the metric you're moving this week"—if you gate all four at once, you'll end up loosening all four.
4. Summary
4.1 Ten-point checklist (copy into review template)
- CLS uses session-window max (1s gap / 5s cap), not sum; filter
hadRecentInputand hidden-period shifts. - LCP freezes at first input; don't use "last" or "max" candidate.
- INP is trimmed 2% typical worst (drop 1 per 50, no trim if ≤50); also track
worstBlockingfor feel. - TBT counts only long tasks before TTI;
Σ(dur − 50ms), don't include 45ms tasks. - Percentiles: store only histograms (global bucket width; log buckets for wide ranges), not pre-computed percentiles.
- Sample once at session level, with weight; store
weight = 1 / sampleRatealongside value. - Report timing:
visibilitychange → hidden+pagehide(bfcache);unloadis unreliable. - Compute bytes per packet (sendBeacon 64KB silently drops); attribution details only on anomalous sessions.
- Collection code must be pure functions, replayable in Node on real entry streams; lock interpretation with assertions.
- Dashboard defaults to p75 + histogram shape; averages are not decision basis.
4.2 Link to previous/next articles
- Day25 rendering pipeline / Day29 runtime performance: this article's CLS/TBT/long tasks are the "physical exam metrics" for those two; Day29's allocation and GC pauses become visible first time in
worstBlocking(§9). - Day26 loading / Day30 bundle: LCP and resource attribution are their acceptance methods; this article's "lab gates, field verifies" is the closure for those optimizations.
- Day32 Worker / Day33 WASM: Worker pools and wasm memory don't appear in JS heap metrics (Day33 tested: 128MB linear memory,
heapUsedonly 15.1MB), so monitoring must add custom watermarks (byteLength,growreturn, high-water mark). - Day35 build tools / Day38 pipeline: §3.5 budget gates go into pipeline.
- Day40 observability: this article is "front-end instrumentation"; Day40 chains trace, logs, metrics into one pipeline—this article's
sessionId/ sampling marker is that chain's correlation ID.
4.3 One sentence
The value of performance monitoring is not in "knowing how fast it currently is", but in "being able to prove it got faster"—and proof requires reproducible interpretation, mergeable aggregation, and quantifiable bias. Miss one step in interpretation and you waste three months of optimization; bias the sampling by 10% and your report lies to you for a year.
Run Environment and File List
Node 22.23.3 / darwin-arm64
day34-assets/
├── vitals.mjs # 指标口径纯函数(CLS/LCP/INP/TBT/直方图/编码)
├── sdk.mjs # 采集·缓冲·flush·采样
├── harness.mjs # 确定性假页面 + entry 流回放
├── test-rum.mjs # 51 条断言(全绿,exit 0)
├── bench.mjs # 本文全部数字的来源
├── bench-log.txt # 实测输出存档
└── extract-and-test.mjs # 从正文 MD 抽代码块,落地再跑
cd day34-assets
node test-rum.mjs # 56 通过 / 0 失败
node bench.mjs # 生成 bench-log.txt
node extract-and-test.mjs # 正文代码块自证(全部 exit 0)
Appendix: Full Code
The following scripts are from
day34-assets/(complete source). Run:node bench.mjsfor end-to-end measurements;node test-rum.mjsto verify RUM data authenticity;node extract-and-test.mjsto extract code blocks from the text and run each segment.
vitals.mjs —— Web Vitals collection core (measurement and calibration of LCP/INP/CLS/TTFB/FCP)
// vitals.mjs —— RUM metric core: pure functions (entry stream -> metric value)
// Design principle: all "metric definitions" are written as side-effect-free pure functions; browser glue only feeds entries.
// This way real entry logs exported from production can be replayed as regression.
/* ---------------- 1. CLS: session window, not sum ---------------- */
// web-vitals spec: drop shifts with hadRecentInput; windows: 1s gap, 5s cap; take max window.
export const CLS_GAP_MS = 1000;
export const CLS_CAP_MS = 5000;
/**
* @param {{value:number,startTime:number,hadRecentInput?:boolean}} entries
* @returns {{cls:number,windows:{value:number,first:number,last:number,count:number}[],maxWindowIndex:number}}
*/
export function clsSessionWindow(entries) {
// Note: can't just filter hadRecentInput at the end; window boundaries must be based on "counted" entries
const used = entries
.filter((e) => !e.hadRecentInput)
.sort((a, b) => a.startTime - b.startTime);
const windows = [];
let cur = null;
for (const e of used) {
if (
cur === null ||
e.startTime - cur.last > CLS_GAP_MS ||
e.startTime - cur.first > CLS_CAP_MS
) {
cur = { value: 0, first: e.startTime, last: e.startTime, count: 0 };
windows.push(cur);
}
cur.value += e.value;
cur.last = e.startTime;
cur.count += 1;
}
let maxWindowIndex = -1;
let cls = 0;
windows.forEach((w, i) => {
if (w.value > cls) {
cls = w.value;
maxWindowIndex = i;
}
});
return { cls: round4(cls), windows, maxWindowIndex };
}
/** Naive approach (many self-built SDKs write this): sum all */
export function clsNaiveSum(entries) {
let s = 0;
for (const e of entries) if (!e.hadRecentInput) s += e.value;
return round4(s);
}
/* ---------------- 2. LCP: candidate replaced until "first input" freezes ---------------- */
/**
* @param {{startTime:number,size:number,element?:string,url?:string}[]} candidates
* @param {{firstInputTime?:number|null}} opts First real interaction time (pointerdown/keydown/click)
*/
export function lcpFromCandidates(candidates, opts = {}) {
const cutoff = opts.firstInputTime ?? Infinity;
let best = null;
for (const c of candidates) {
// Candidates after first input are invalid: LCP is frozen
if (c.startTime > cutoff) continue;
if (!best || c.size > best.size) best = c;
}
return best
? { value: round2(best.startTime), size: best.size, element: best.element ?? 'unknown', url: best.url ?? '' }
: { value: 0, size: 0, element: null, url: '' };
}
/** Naive approach: take the "last" candidate as LCP (measured to be biased, depending on whether a larger image exists later) */
export function lcpLastCandidate(candidates) {
const c = candidates[candidates.length - 1];
return c ? { value: round2(c.startTime), size: c.size } : { value: 0, size: 0 };
}
/* ---------------- 3. INP: worst interaction, but "trimmed" ---------------- */
export const INTERACTION_MAX_MS = 500; // interaction window cap
export const INP_TRIMMED_PCT = 0.98;
/**
* web-vitals trim: drop 1 highest per 50 interactions (≈98th percentile),
* but if interactions <= 50, don't trim (better to suffer a long tail than misjudge with insufficient samples).
*/
export function trimmedInteractionCount(total) {
if (total <= 50) return total;
return total - Math.floor(total / 50);
}
/**
* @param {{startTime:number,latency:number,name?:string}[]} interactions
*/
export function inpFromInteractions(interactions) {
if (interactions.length === 0) return { value: 0, target: null, total: 0, kept: 0 };
const sorted = [...interactions].sort((a, b) => a.latency - b.latency);
const kept = Math.max(1, trimmedInteractionCount(sorted.length));
// Trim = drop the highest (total - kept)
const idx = kept - 1;
const pick = sorted[idx];
return {
value: round2(pick.latency),
target: pick.name ?? null,
total: sorted.length,
kept,
};
}
/** Naive approach: directly max */
export function inpNaiveMax(interactions) {
let m = 0;
for (const i of interactions) if (i.latency > m) m = i.latency;
return round2(m);
}
/** Latency of one interaction = processing end - start (simplified, without presentation delay, but includes queueing + processing) */
export function interactionLatency(startTime, processingEnd) {
if (processingEnd <= startTime) return 0;
return round2(Math.min(processingEnd - startTime, INTERACTION_MAX_MS));
}
/* ---------------- 4. TBT: total main-thread blocking time ---------------- */
export const LONG_TASK_MS = 50;
/**
* TBT = Σ over tasks (dur - 50), and only counts tasks before TTI.
* Here we only count up to freezeTime; TTI is approximated by firstInputTime.
*/
export function tbtFromLongTasks(tasks, opts = {}) {
const until = opts.until ?? Infinity;
let tbt = 0;
let worst = 0;
let count = 0;
for (const t of tasks) {
if (t.startTime > until) continue;
const over = Math.max(0, t.duration - LONG_TASK_MS);
if (over > 0) {
tbt += over;
count += 1;
if (over > worst) worst = over;
}
}
return { tbt: round2(tbt), worstBlocking: round2(worst), longTasks: count };
}
/* ---------------- 5. Percentile: histogram / HDR / reservoir ---------------- */
export function exactPercentile(values, p) {
if (values.length === 0) return 0;
const s = [...values].sort((a, b) => a - b);
// linear interpolation (same as numpy default)
const rank = (s.length - 1) * p;
const lo = Math.floor(rank);
const hi = Math.ceil(rank);
if (lo === hi) return s[lo];
return s[lo] + (s[hi] - s[lo]) * (rank - lo);
}
/** Fixed-bucket-width histogram (most common), returns mergeable bucket counts */
export function histogramFixed(values, bucketMs) {
const buckets = new Map();
for (const v of values) {
const b = Math.floor(v / bucketMs);
buckets.set(b, (buckets.get(b) ?? 0) + 1);
}
return { buckets, bucketMs, total: values.length };
}
/** HDR-style: logarithmic scale (10 buckets per decade), narrow range small error, wide range larger but controlled */
export function histogramLog(values, bucketsPerDecade = 10) {
const buckets = new Map();
const k = bucketsPerDecade / Math.LN10;
for (const v of values) {
const b = Math.floor(Math.log(Math.max(v, 1)) * k);
buckets.set(b, (buckets.get(b) ?? 0) + 1);
}
return { buckets, bucketsPerDecade, total: values.length };
}
/** Percentile from histogram: use bucket midpoint (error upper bound = bucket width / 2) */
export function histogramPercentile(h, p) {
const target = p * h.total;
let acc = 0;
const keys = [...h.buckets.keys()].sort((a, b) => a - b);
for (const key of keys) {
const c = h.buckets.get(key);
acc += c;
if (acc >= target) {
if (h.bucketMs) return (key + 0.5) * h.bucketMs;
const k = h.bucketsPerDecade / Math.LN10;
return Math.exp((key + 0.5) / k);
}
}
return NaN;
}
/** Client-side sampling (reservoir) — note: it computes the sample's percentile, not the population's */
export function reservoirSample(values, n, rand = mulberry32(42)) {
const res = [];
for (let i = 0; i < values.length; i++) {
if (i < n) res.push(values[i]);
else {
const j = Math.floor(rand() * (i + 1));
if (j < n) res[j] = values[i];
}
}
return res;
}
/** Merge multiple machines/sample-rate histograms: bucket-wise add (histograms' killer advantage) */
export function mergeHistograms(hists) {
const buckets = new Map();
let total = 0;
for (const h of hists) {
total += h.total;
for (const [k, c] of h.buckets) buckets.set(k, (buckets.get(k) ?? 0) + c);
}
const first = hists[0];
return { buckets, bucketMs: first.bucketMs, bucketsPerDecade: first.bucketsPerDecade, total };
}
/** Weighted merge of raw samples (different sampling rates, direct mixing biases) */
export function weightedPercentile(groups) {
const pts = [];
let wsum = 0;
for (const g of groups) {
for (const v of g.values) pts.push([v, g.weight]);
wsum += g.weight * g.values.length;
}
pts.sort((a, b) => a[0] - b[0]);
return { points: pts, wsum };
}
export function weightedPercentileValue(groups, p) {
const { points, wsum } = weightedPercentile(groups);
const target = p * wsum;
let acc = 0;
for (const [v, w] of points) {
acc += w;
if (acc >= target) return v;
}
return points.at(-1)?.[0] ?? 0;
}
/* ---------------- 6. Reporting: compact encoding + budget ---------------- */
export const BEACON_LIMIT_BYTES = 64 * 1024; // sendBeacon actual limit ~64KB, silently drops over
/** Hand-rolled "columnar + integer" encoding, payload size can be a fraction of JSON */
export function encodeReport(reports) {
// Columnar: field names appear once, values queue by column
const cols = {
n: reports.map((r) => r.name),
v: reports.map((r) => Math.round(r.value * 1000) / 1000),
t: reports.map((r) => Math.round(r.ts)),
};
// Optional columns (attribution): if any entry has it, the whole column must exist and be padded.
// Without this, attribution is silently dropped—small payload but the problem is undetectable.
for (const k of ['el', 'url', 'ev']) {
if (reports.some((r) => r[k] !== undefined)) cols[k] = reports.map((r) => r[k] ?? null);
}
return JSON.stringify({ k: Object.keys(cols), c: cols });
}
/** Decode: verify attribution didn't vanish in encoding */
export function decodeReport(payload) {
const { k, c } = typeof payload === 'string' ? JSON.parse(payload) : payload;
const n = c[k[0]].length;
const rows = [];
for (let i = 0; i < n; i++) {
const row = {};
for (const key of k) row[key] = c[key][i];
rows.push(row);
}
return rows;
}
export function jsonBytes(obj) {
return Buffer.byteLength(JSON.stringify(obj), 'utf8');
}
/* ---------------- Utilities ---------------- */
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function lognormal(rand, mu, sigma) {
const u1 = Math.max(rand(), 1e-12);
const u2 = rand();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return Math.exp(mu + sigma * z);
}
const round2 = (x) => Math.round(x * 100) / 100;
const round4 = (x) => Math.round(x * 10000) / 10000;
sdk.mjs —— Reporting SDK (sampling, eviction, batching, offline retry)
// sdk.mjs — collection layer: feed entry streams into pure functions, manage "when to compute, when to send, how big"
// In browser: new Rum({endpoint}) directly; in Node: inject fake window via harness.mjs.
import {
clsSessionWindow,
lcpFromCandidates,
inpFromInteractions,
tbtFromLongTasks,
interactionLatency,
encodeReport,
BEACON_LIMIT_BYTES,
} from './vitals.mjs';
export class Rum {
/**
* @param {{
* endpoint:string,
* sampleRate?:number, // session-level sampling
* rand?:()=>number,
* flushIntervalMs?:number,
* maxBuffer?:number, // buffer entry cap; if exceeded must evict (which? see below)
* transport?:(url:string,payload:string)=>void,
* now?:()=>number,
* }} opts
*/
constructor(opts) {
this.endpoint = opts.endpoint;
this.sampleRate = opts.sampleRate ?? 1;
this.rand = opts.rand ?? Math.random;
this.flushIntervalMs = opts.flushIntervalMs ?? 10000;
this.maxBuffer = opts.maxBuffer ?? 200;
this.transport = opts.transport ?? (() => {});
this.now = opts.now ?? (() => Date.now());
// Sampling decision made once at session start, not per metric:
// otherwise you get "CLS sampled but LCP not" broken sessions
this.sampled = this.rand() < this.sampleRate;
this.shifts = [];
this.lcpCandidates = [];
this.interactions = [];
this.longTasks = [];
this.firstInputTime = null;
this.lcpFrozen = null;
this.buffer = [];
this.dropped = 0;
this.sent = 0;
this.bytesSent = 0;
this.flushes = 0;
this.lastFlushAt = 0;
}
/* ---- Collection entries: all from PerformanceObserver, plain data ---- */
onLayoutShift(entry, atTime) {
// Shifts during hidden state not counted (web-vitals spec), otherwise background-tab ads pollute CLS
if (this.hidden) return;
this.shifts.push({ value: entry.value, startTime: atTime, hadRecentInput: !!entry.hadRecentInput });
}
onLcpCandidate(entry, atTime) {
this.lcpCandidates.push({ startTime: atTime, size: entry.size, element: entry.element, url: entry.url });
}
onEvent(entry) {
// First real input → freeze LCP
if (this.firstInputTime === null && entry.interactionId) {
this.firstInputTime = entry.startTime;
this.lcpFrozen = this.computeLcp();
this.push('lcp', this.lcpFrozen.value, entry.startTime);
}
}
onInteraction(entry) {
const lat = interactionLatency(entry.startTime, entry.processingEnd);
if (lat <= 0) return;
this.interactions.push({ startTime: entry.startTime, latency: lat, name: entry.name });
}
onLongTask(entry) {
this.longTasks.push({ startTime: entry.startTime, duration: entry.duration });
}
onHidden(atTime) {
this.hidden = true;
// Only trustworthy "last moment": visibilitychange -> hidden.
// unload/beforeunload unreliable on mobile/bfcache; don't bet on them.
this.finalize(atTime);
this.flush('hidden');
}
/* ---- Computation ---- */
computeLcp() {
if (this.lcpFrozen) return this.lcpFrozen;
return lcpFromCandidates(this.lcpCandidates, { firstInputTime: this.firstInputTime ?? Infinity });
}
finalize(atTime) {
const cls = clsSessionWindow(this.shifts);
this.push('cls', cls.cls, atTime);
if (!this.lcpFrozen) {
this.lcpFrozen = this.computeLcp();
this.push('lcp', this.lcpFrozen.value, atTime);
}
const inp = inpFromInteractions(this.interactions);
this.push('inp', inp.value, atTime);
const tbt = tbtFromLongTasks(this.longTasks, { until: this.firstInputTime ?? atTime });
this.push('tbt', tbt.tbt, atTime);
return { cls, inp, tbt };
}
/* ---- Buffer: when full, evict "least important" not oldest ---- */
push(name, value, ts) {
if (!this.sampled) return;
if (!(value >= 0)) return; // NaN / negative guard: metric pollution worse than dropping
const item = { name, value, ts };
if (this.buffer.length >= this.maxBuffer) {
this.dropped += 1;
// Tail-first eviction: debug/resource details go first, core metrics retained
const victim = this.buffer.findIndex((b) => b.name.startsWith('dbg.'));
if (victim >= 0) this.buffer.splice(victim, 1);
else return; // buffer all core metrics → prefer dropping new point over computed core
}
this.buffer.push(item);
}
flush(reason = 'interval') {
if (!this.sampled) return { reason, sent: 0, bytes: 0, skipped: 'not-sampled' };
if (this.buffer.length === 0) return { reason, sent: 0, bytes: 0, skipped: 'empty' };
this.flushes += 1;
this.lastFlushAt = this.now();
let payload = encodeReport(this.buffer);
let n = this.buffer.length;
// Single-packet budget: over 64KB, split. sendBeacon doesn't throw, silently drops.
if (Buffer.byteLength(payload, 'utf8') > BEACON_LIMIT_BYTES - 1024) {
const half = Math.ceil(this.buffer.length / 2);
const head = this.buffer.slice(0, half);
this.buffer = this.buffer.slice(half);
payload = encodeReport(head);
n = head.length;
} else {
this.buffer = [];
}
this.transport(this.endpoint, payload);
this.sent += n;
this.bytesSent += Buffer.byteLength(payload, 'utf8');
return { reason, sent: n, bytes: Buffer.byteLength(payload, 'utf8') };
}
}
/** Main-thread overhead probe: distinguish "observation cost" from "business cost" */
export function makeObserverProbe(perf, { samples = 1000 } = {}) {
const before = perf.now();
for (let i = 0; i < samples; i++) {
perf.mark(`p${i}`);
perf.measure(`m${i}`, `p${i}`);
}
const withMark = perf.now() - before;
return { perMarkUs: (withMark / samples) * 1000 };
}
harness.mjs —— Benchmark driver (run in real Chrome with CDP injection)
// harness.mjs — run the SDK in a deterministic fake page: given entry stream, produce report
// Benefit: real entry logs exported from production can be replayed verbatim for regression, no browser needed.
import { Rum } from './sdk.mjs';
import { mulberry32 } from './vitals.mjs';
export function createSession(opts = {}) {
const sent = [];
const rum = new Rum({
endpoint: 'https://r.example.com/collect',
sampleRate: opts.sampleRate ?? 1,
rand: opts.rand ?? mulberry32(1),
maxBuffer: opts.maxBuffer ?? 200,
transport: (url, payload) => sent.push({ url, payload }),
now: opts.now ?? (() => 1_700_000_000_000),
});
return { rum, sent };
}
/** A "typical" middle-of-the-road page entry stream */
export function typicalPage(seed = 7) {
const rand = mulberry32(seed);
const shifts = [];
// first screen skeleton → content replacement: one 0.12 shift at 1800ms
shifts.push({ value: 0.12, startTime: 1800, hadRecentInput: false });
// user scrolls, ad inserted: 3 small shifts (after click → hadRecentInput)
shifts.push({ value: 0.05, startTime: 3200, hadRecentInput: true });
shifts.push({ value: 0.04, startTime: 3600, hadRecentInput: true });
// background timer expands image: one more real shift after 200ms
shifts.push({ value: 0.08, startTime: 4200, hadRecentInput: false });
const lcp = [
{ startTime: 900, size: 20000, element: 'h1' },
{ startTime: 1400, size: 90000, element: 'img.hero' },
{ startTime: 2100, size: 60000, element: 'img.secondary' }, // later but smaller → not selected
{ startTime: 5000, size: 400000, element: 'img.lazyloaded' }, // after first input → invalid
];
const interactions = [
{ startTime: 3300, processingEnd: 3380, name: 'pointerdown', interactionId: 1 },
{ startTime: 5100, processingEnd: 5300, name: 'keydown', interactionId: 2 },
{ startTime: 7100, processingEnd: 7160, name: 'click', interactionId: 3 },
];
const longTasks = [
{ startTime: 1200, duration: 220 },
{ startTime: 1900, duration: 80 },
{ startTime: 4800, duration: 45 }, // < 50ms, not a long task
];
return { shifts, lcp, interactions, longTasks, rand };
}
/** Feed entry stream into SDK, return { report, sent, rum } */
export function runSession(entries, opts = {}) {
const { rum, sent } = createSession(opts);
const hiddenAt = opts.hiddenAt ?? 9000;
for (const s of entries.shifts) if (s.startTime <= hiddenAt) rum.onLayoutShift(s, s.startTime);
for (const c of entries.lcp) if (c.startTime <= hiddenAt) rum.onLcpCandidate(c, c.startTime);
for (const i of entries.interactions) {
if (i.startTime > hiddenAt) continue;
rum.onEvent(i);
rum.onInteraction(i);
}
for (const t of entries.longTasks) if (t.startTime <= hiddenAt) rum.onLongTask(t);
rum.onHidden(hiddenAt);
return { report: JSON.parse(sent.at(-1)?.payload ?? '{"c":{}}'), sent, rum };
}
test-rum.mjs —— RUM data anti-cheat tests (identify fake data and sampling bias)
// test-rum.mjs — turn every interpretation into an assertion. All green = article credible.
import {
clsSessionWindow,
clsNaiveSum,
lcpFromCandidates,
inpFromInteractions,
inpNaiveMax,
trimmedInteractionCount,
tbtFromLongTasks,
exactPercentile,
histogramFixed,
histogramPercentile,
histogramLog,
mergeHistograms,
weightedPercentileValue,
reservoirSample,
interactionLatency,
encodeReport,
decodeReport,
jsonBytes,
} from './vitals.mjs';
import { runSession, typicalPage, createSession } from './harness.mjs';
let pass = 0;
const fails = [];
function ok(cond, msg) {
if (cond) { pass++; console.log(` ✅ ${msg}`); }
else { fails.push(msg); console.log(` ❌ ${msg}`); }
}
function eq(a, b, msg, tol = 1e-9) {
ok(Math.abs(a - b) <= tol, `${msg} (got ${a}, want ${b})`);
}
console.log('\n== 1. CLS:会话窗口 ==');
{
// single window: three consecutive (gap<1s)
const w1 = clsSessionWindow([
{ value: 0.1, startTime: 1000 },
{ value: 0.2, startTime: 1500 },
{ value: 0.05, startTime: 2200 },
]);
eq(w1.cls, 0.35, '间隔<1s 的 shift 归入同一窗口');
ok(w1.windows.length === 1, '只产生 1 个窗口');
// gap 1.5s → break
const w2 = clsSessionWindow([
{ value: 0.1, startTime: 1000 },
{ value: 0.3, startTime: 2500 },
]);
eq(w2.cls, 0.3, '间隔>1s 断开 → 取最大窗口而非总和');
eq(clsNaiveSum([{ value: 0.1, startTime: 1000 }, { value: 0.3, startTime: 2500 }]), 0.4, '朴素求和 = 0.4(错的)');
// 5s cap: consecutive every 900ms run 9s
const many = [];
for (let t = 0; t <= 9000; t += 900) many.push({ value: 0.01, startTime: t });
const w3 = clsSessionWindow(many);
ok(w3.windows.length >= 2, `5s 上限生效(${w3.windows.length} 个窗口)`);
ok(w3.windows.every((w) => w.last - w.first <= 5000), '每个窗口跨度 <= 5000ms');
ok(w3.cls <= 0.06 + 1e-9, `最大窗口 ${w3.cls} <= 6 条 ×0.01`);
// hadRecentInput entries don't count at all (nor affect window boundaries)
const w4 = clsSessionWindow([
{ value: 0.5, startTime: 1000, hadRecentInput: true },
{ value: 0.05, startTime: 1500, hadRecentInput: false },
]);
eq(w4.cls, 0.05, '用户输入 500ms 内的 shift 被丢弃');
}
console.log('\n== 2. LCP:首次输入冻结 ==');
{
const c = [
{ startTime: 900, size: 20000, element: 'h1' },
{ startTime: 1400, size: 90000, element: 'img.hero' },
{ startTime: 5000, size: 400000, element: 'lazy' },
];
eq(lcpFromCandidates(c, { firstInputTime: Infinity }).value, 5000, '无输入 → 取最大 size 的候选(5000ms)');
eq(lcpFromCandidates(c, { firstInputTime: 3300 }).value, 1400, '首次输入 3300 → 冻结在 1400ms');
eq(lcpFromCandidates(c, { firstInputTime: 1000 }).value, 900, '首次输入 1000 → 冻结在 900ms');
eq(lcpFromCandidates([], {}).value, 0, '无候选 → 0');
}
console.log('\n== 3. INP:截尾 ==');
{
eq(trimmedInteractionCount(10), 10, '交互 10 次不截尾');
eq(trimmedInteractionCount(50), 50, '交互 50 次不截尾');
eq(trimmedInteractionCount(100), 98, '交互 100 次保留 98');
eq(trimmedInteractionCount(1000), 980, '交互 1000 次保留 980');
const few = [{ latency: 80 }, { latency: 120 }, { latency: 40 }];
eq(inpFromInteractions(few).value, 120, '3 次交互 → 取最差 120');
const many = [];
for (let i = 0; i < 100; i++) many.push({ latency: 50 + i });
const inp = inpFromInteractions(many);
eq(inp.kept, 98, '100 次 → 保留 98');
// 排序后第 98 个(index 97)= 50+97 = 147,丢掉 148/149
eq(inp.value, 147, '截尾后第 98 个 = 147ms(max 是 149)');
ok(inp.value < 149, '没有直接被单个尖刺带走');
// 关键取舍:200 次交互截掉最高 4 个 → 1~4 个尖刺会被整族抹掉,第 5 个才露头
const mkSpikes = (spikes) => {
const a = [];
for (let i = 0; i < 200; i++) a.push({ latency: i >= 200 - spikes ? 480 : 60 });
return a;
};
eq(inpFromInteractions(mkSpikes(1)).value, 60, '200 次里 1 个尖刺 → 被截尾抹掉(报 60)');
eq(inpFromInteractions(mkSpikes(4)).value, 60, '200 次里 4 个尖刺 → 仍被抹掉(截尾 4 个)');
eq(inpFromInteractions(mkSpikes(5)).value, 480, '200 次里 5 个尖刺 → 第 5 个露头,报 480');
ok(inpFromInteractions(mkSpikes(1)).value !== inpNaiveMax(mkSpikes(1)), 'INP 与"最差交互"是两个不同指标');
eq(interactionLatency(100, 800), 500, '交互 latency 被 500ms 上限截断');
eq(interactionLatency(100, 90), 0, '结束早于开始 → 0(脏数据守卫)');
}
console.log('\n== 4. TBT ==');
{
const t = tbtFromLongTasks([
{ startTime: 1200, duration: 220 },
{ startTime: 1900, duration: 80 },
{ startTime: 4800, duration: 45 },
]);
eq(t.tbt, 200, 'TBT = (220-50)+(80-50) = 200,45ms 不算长任务');
eq(t.worstBlocking, 170, '最差阻塞 170ms');
eq(t.longTasks, 2, '长任务 2 个');
eq(tbtFromLongTasks([{ startTime: 9000, duration: 500 }], { until: 3300 }).tbt, 0, 'TTI 之后的长任务不计入 TBT');
}
console.log('\n== 5. 分位与直方图 ==');
{
eq(exactPercentile([1, 2, 3, 4], 0.75), 3.25, 'p75 线性插值 = 3.25');
const vals = [10, 20, 30, 40, 100];
const h = histogramFixed(vals, 10);
eq(histogramPercentile(h, 0.5), 35, '10ms 桶直方图 p50 误差 <= 5ms(桶中点为 35)');
const merged = mergeHistograms([histogramFixed([10, 20], 10), histogramFixed([30, 40, 100], 10)]);
eq(merged.total, 5, '直方图合并后 total = 5');
eq(histogramPercentile(merged, 0.8), 45, '合并直方图 p80 = 45(精确值 52,误差 = 桶宽/2 量级)');
ok(Math.abs(histogramPercentile(merged, 0.8) - exactPercentile([10, 20, 30, 40, 100], 0.8)) <= 10, '直方图 p80 误差 <= 10ms');
const mk = (n, seed) => {
const out = [];
let a = seed;
for (let i = 0; i < n; i++) {
a = (a * 1103515245 + 12345) & 0x7fffffff;
out.push(100 + (a % 1000));
}
return out;
};
const data = mk(2000, 3);
const exact = exactPercentile(data, 0.75);
const approx = histogramPercentile(histogramFixed(data, 50), 0.75);
ok(Math.abs(exact - approx) <= 25, `50ms 桶直方图 p75 误差 ${Math.abs(exact - approx).toFixed(1)}ms <= 25ms`);
const w = weightedPercentileValue(
[
{ values: [10, 20, 30], weight: 3 }, // 采样率低 → 权重高
{ values: [100, 110, 120], weight: 1 },
],
0.5,
);
ok(w >= 10 && w <= 30, `加权合并 p50 = ${w}(落在权重更高的那批里)`);
const naive = exactPercentile([10, 20, 30, 100, 110, 120], 0.5);
ok(naive > w, `不加权混算 p50 = ${naive} 明显偏大 → 采样必须带权重`);
}
console.log('\n== 6. 会话级采样与上报 ==');
{
const page = typicalPage();
const { report, sent } = runSession(page);
const idx = (n) => report.c.n.indexOf(n);
eq(report.c.v[idx('cls')], 0.12, '端到端 CLS = 0.12(max 窗口,不是 0.20 总和)');
eq(report.c.v[idx('lcp')], 1400, '端到端 LCP = 1400ms');
eq(report.c.v[idx('inp')], 200, '端到端 INP = 200ms(5300-5100)');
eq(report.c.v[idx('tbt')], 200, '端到端 TBT = 200ms');
ok(sent.length === 1, `hidden 时只发 1 包(实际 ${sent.length})`);
ok(encodeReport([{ name: 'cls', value: 0.12, ts: 1 }]).includes('"k"'), '紧凑编码采用列式');
// 不采样 → 一个字节都不发
const noSample = runSession(page, { sampleRate: 0, rand: () => 0.99 });
ok(noSample.sent.length === 0, 'sampleRate=0 → 零上报');
// 缓冲区打满 → 先丢 dbg 类
const s = createSession({ maxBuffer: 4 });
for (let i = 0; i < 3; i++) s.rum.push('dbg.metric', i, i);
s.rum.push('cls', 0.1, 10);
s.rum.push('lcp', 900, 11);
ok(s.rum.dropped === 1, '超限时丢弃 1 条');
ok(s.rum.buffer.some((b) => b.name === 'cls') && s.rum.buffer.some((b) => b.name === 'lcp'), '核心指标未被丢');
ok(!s.rum.buffer.some((b) => b.name === 'dbg.metric' && b.value === 0), '首条 dbg 明细被挤出');
}
console.log('\n== 7. 脏数据守卫 ==');
{
const s = createSession();
s.rum.push('cls', NaN, 1);
s.rum.push('lcp', -1, 2);
s.rum.push('inp', 100, 3);
ok(s.rum.buffer.length === 1, 'NaN/负数被丢弃,只留合法点');
const hidden = createSession();
hidden.rum.hidden = true;
hidden.rum.onLayoutShift({ value: 0.9 }, 100);
ok(hidden.rum.shifts.length === 0, '页面隐藏期间的 shift 不入账');
}
console.log('\n== 8. 体积 ==');
{
const reports = [];
for (let i = 0; i < 100; i++) reports.push({ name: `custom.metric.${i % 7}`, value: i * 1.5, ts: 1_700_000_000_000 + i });
const compact = encodeReport(reports);
const plain = jsonBytes({ reports });
const ratio = jsonBytes({ compact }) / plain;
ok(ratio < 0.85, `列式编码体积 ${(ratio * 100).toFixed(1)}% of 朴素 JSON`);
}
console.log('\n== 9. 列式编码的归因往返 ==');
{
const rows = [
{ name: 'lcp', value: 1234.5, ts: 1_700_000_000_000, el: 'img.hero', url: '/a.webp', ev: 'scroll' },
{ name: 'inp', value: 210, ts: 1_700_000_000_001, el: 'button#buy' },
{ name: 'cls', value: 0.08, ts: 1_700_000_000_002 },
];
const enc = encodeReport(rows);
const dec = decodeReport(enc);
ok(dec.length === 3, '列式编码往返条数一致');
ok(dec[0].el === 'img.hero' && dec[0].url === '/a.webp' && dec[0].ev === 'scroll', '第 1 条归因字段完整');
ok(dec[1].url === null, '缺省归因补 null,列长对齐');
ok(dec[2].el === null && dec[2].ev === null, '无归因条目也补齐');
// 反例:不加可选列会怎样
const lossy = JSON.stringify({ k: ['n', 'v', 't'], c: { n: rows.map((r) => r.name), v: rows.map((r) => r.value), t: rows.map((r) => r.ts) } });
ok(!lossy.includes('img.hero'), '只编码三列 → 归因字段静默消失(体积好看了,问题查不出)');
}
console.log(`\n结果: ${pass} 通过, ${fails.length} 失败`);
if (fails.length) { fails.forEach((f) => console.log(' FAIL:', f)); process.exit(1); }
process.exit(0);
bench.mjs —— Benchmark main program (vitlas end-to-end measurement chain)
// bench.mjs — all numbers come from here, not hand-written
import { performance } from 'node:perf_hooks';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync } from 'node:zlib';
import {
clsSessionWindow,
clsNaiveSum,
lcpFromCandidates,
inpFromInteractions,
inpNaiveMax,
tbtFromLongTasks,
exactPercentile,
histogramFixed,
histogramLog,
histogramPercentile,
mergeHistograms,
reservoirSample,
weightedPercentileValue,
encodeReport,
jsonBytes,
mulberry32,
lognormal,
} from './vitals.mjs';
import { runSession, typicalPage, createSession } from './harness.mjs';
const out = [];
function log(...a) {
const line = a.join(' ');
out.push(line);
console.log(line);
}
const f = (x, d = 2) => Number(x).toFixed(d);
const pct = (x) => `${(x * 100).toFixed(1)}%`;
/* ---------- 0. Environment ---------- */
log('== 0. 环境 ==');
log(`node ${process.version} / ${process.platform}-${process.arch}`);
log('');
/* ---------- 1. CLS: how much does naive sum inflate p75 ---------- */
log('== 1. CLS 会话窗口 vs 朴素求和(500 个模拟会话)==');
{
const rand = mulberry32(11);
const sumVals = [];
const winVals = [];
for (let s = 0; s < 500; s++) {
const entries = [];
// navigation lifecycle: skeleton replacement + several image/font expansions + occasional ad slot
const baseline = rand() * 2;
entries.push({ value: 0.02 + rand() * 0.1, startTime: 800 + rand() * 600 });
// 3~14 subsequent shifts, gap 200~4000ms (real gaps are bimodal: consecutive renders tight, different batches sparse)
const k = 3 + Math.floor(rand() * 12);
let t = 1200;
for (let i = 0; i < k; i++) {
t += rand() < 0.55 ? 200 + rand() * 700 : 1500 + rand() * 3000;
entries.push({
value: 0.01 + rand() * 0.08,
startTime: t,
hadRecentInput: rand() < 0.35,
});
}
sumVals.push(clsNaiveSum(entries));
winVals.push(clsSessionWindow(entries).cls);
}
const p = (arr, q) => exactPercentile(arr, q);
log(` 朴素求和 p50=${f(p(sumVals, 0.5), 4)} p75=${f(p(sumVals, 0.75), 4)} p95=${f(p(sumVals, 0.95), 4)}`);
log(` 会话窗口 p50=${f(p(winVals, 0.5), 4)} p75=${f(p(winVals, 0.75), 4)} p95=${f(p(winVals, 0.95), 4)}`);
const inflate = p(sumVals, 0.75) / p(winVals, 0.75);
log(` → p75 被朴素求和放大 ${f(inflate)}x(CWV 门槛 0.1 会从"良好"变成"需改进")`);
const goodNaive = sumVals.filter((v) => v <= 0.1).length / sumVals.length;
const goodWin = winVals.filter((v) => v <= 0.1).length / winVals.length;
log(` → 判定"良好(<=0.1)"的会话占比:朴素口径 ${pct(goodNaive)} vs 正确口径 ${pct(goodWin)}(朴素把"良好"低估了 ${f((goodWin - goodNaive) * 100)} 个百分点)`);
log('');
}
/* ---------- 2. Percentile estimator error ---------- */
log('== 2. 分位估计器误差:精度 vs 存储(LCP 长尾分布, n=20000)==');
{
const rand = mulberry32(23);
const lcp = [];
for (let i = 0; i < 20000; i++) lcp.push(lognormal(rand, 7.1, 0.55)); // median ~1210ms
const truth = { p50: exactPercentile(lcp, 0.5), p75: exactPercentile(lcp, 0.75), p95: exactPercentile(lcp, 0.95) };
log(` 真值 p50=${f(truth.p50, 1)}ms p75=${f(truth.p75, 1)}ms p95=${f(truth.p95, 1)}ms`);
const rows = [];
const test = (name, est, sizeBytes) => {
const e = { p50: est(0.5), p75: est(0.75), p95: est(0.95) };
rows.push({
name,
e50: ((e.p50 - truth.p50) / truth.p50) * 100,
e75: ((e.p75 - truth.p75) / truth.p75) * 100,
e95: ((e.p95 - truth.p95) / truth.p95) * 100,
size: sizeBytes,
});
};
const hSize = (h) => h.buckets.size;
const h100 = histogramFixed(lcp, 100);
test('固定桶 100ms', (q) => histogramPercentile(h100, q), hSize(h100));
const h50 = histogramFixed(lcp, 50);
test('固定桶 50ms', (q) => histogramPercentile(h50, q), hSize(h50));
const h10 = histogramFixed(lcp, 10);
test('固定桶 10ms', (q) => histogramPercentile(h10, q), hSize(h10));
const hlog = histogramLog(lcp, 10);
test('对数桶 10/decade', (q) => histogramPercentile(hlog, q), hSize(hlog));
const hlog20 = histogramLog(lcp, 20);
test('对数桶 20/decade', (q) => histogramPercentile(hlog20, q), hSize(hlog20));
// reservoir is random: single-sample error misinterpreted as estimator characteristic; must repeat R times and average absolute error
const R = 30;
const resErr = (n) => {
const e = { p50: 0, p75: 0, p95: 0 };
for (let r = 0; r < R; r++) {
const s = reservoirSample(lcp, n, mulberry32(1000 + r));
for (const q of [0.5, 0.75, 0.95]) {
e['p' + q * 100] += Math.abs((exactPercentile(s, q) - truth['p' + q * 100]) / truth['p' + q * 100]) / R;
}
}
return { e, size: n };
};
for (const n of [100, 500, 2000]) {
const { e } = resErr(n);
rows.push({ name: `蓄水池 n=${n}`, e50: e.p50 * 100, e75: e.p75 * 100, e95: e.p95 * 100, size: n, abs: true });
}
rows.push({ name: '全量精确(不可扩展)', e50: 0, e75: 0, e95: 0, size: lcp.length });
log(' 估计器 p50误差 p75误差 p95误差 桶/样本数 (误差为相对真值)');
for (const r of rows) {
const fmt = (x) => (r.abs ? `${f(x, 2)}|` : `${f(x, 2)}%`).padStart(8);
log(` ${r.name.padEnd(22)}${fmt(r.e50)}${fmt(r.e75)}${fmt(r.e95)}${String(r.size).padStart(10)}${r.abs ? ' (|偏差|均值)' : ''}`);
}
log(` → 100ms 固定桶 p75 误差 ${f(Math.abs(rows[0].e75), 1)}%,比 50ms 桶(计数 ${hSize(h50)}) 少一半存储但精度差 ${f(Math.abs(rows[0].e75) / Math.max(Math.abs(rows[1].e75), 1e-9), 1)}x`);
const r100row = rows.find((r) => r.name === '蓄水池 n=100');
const r2000row = rows.find((r) => r.name === '蓄水池 n=2000');
log(` → 蓄水池 n=100:p75 平均偏差 ${f(r100row.e75, 2)}%、p95 平均偏差 ${f(r100row.e95, 2)}%(尾部最不稳)`);
log(` → 蓄水池 n=2000:p75 平均偏差 ${f(r2000row.e75, 2)}% —— 存储涨 20x 只换来个位数精度提升`);
log(` → 蓄水池存的是"样本",样本分位 ≠ 总体分位;直方图存的是"分布",所以可合并`);
log('');
}
/* ---------- 3. Merging two batches with different sample rates ---------- */
log('== 3. 合并两个采样率不同的批次(移动端 1% vs 桌面 10%)==');
{
const rand = mulberry32(31);
// mobile is slow and accounts for 80% traffic, sampled at 1% → only 50 of reported 500 are mobile
const mobileAll = Array.from({ length: 50000 }, () => lognormal(rand, 7.5, 0.55));
const desktopAll = Array.from({ length: 12000 }, () => lognormal(rand, 6.9, 0.45));
const mobileSampled = mobileAll.filter((_, i) => i % 100 === 0); // 1%
const desktopSampled = desktopAll.filter((_, i) => i % 10 === 0); // 10%
const truth = exactPercentile([...mobileAll, ...desktopAll], 0.75);
const naive = exactPercentile([...mobileSampled, ...desktopSampled], 0.75);
const weighted = weightedPercentileValue(
[
{ values: mobileSampled, weight: 100 },
{ values: desktopSampled, weight: 10 },
],
0.75,
);
log(` 移动端样本 ${mobileSampled.length} 条(真实 ${mobileAll.length},权重 100)`);
log(` 桌面端样本 ${desktopSampled.length} 条(真实 ${desktopAll.length},权重 10)`);
log(` 总体真值 p75 = ${f(truth, 1)}ms`);
log(` 直接混算 p75 = ${f(naive, 1)}ms(误差 ${f((naive - truth) / truth * 100, 1)}%)`);
log(` 加权合并 p75 = ${f(weighted, 1)}ms(误差 ${f((weighted - truth) / truth * 100, 1)}%)`);
log(' → 采样偏差不是"随机噪声",是系统性偏移:谁的采样率高,p75 就被谁带跑');
log('');
}
/* ---------- 4. Histogram mergeable vs samples not mergeable ---------- */
log('== 4. 为什么必须存直方图:分片机房的合并 ==');
{
const rand = mulberry32(41);
const shards = [];
for (let s = 0; s < 8; s++) {
const vals = Array.from({ length: 2500 }, () => lognormal(rand, 7.0 + s * 0.05, 0.4));
shards.push(vals);
}
const all = shards.flat();
const truth = exactPercentile(all, 0.9);
// each shard maintains 50ms-bucket histogram → server sums
const merged = mergeHistograms(shards.map((v) => histogramFixed(v, 50)));
const approx = histogramPercentile(merged, 0.9);
// counterexample: each shard sends p90 scalar → cannot merge
const perShardP90 = shards.map((v) => exactPercentile(v, 0.9));
log(` 真值 p90 = ${f(truth, 1)}ms`);
log(` 8 片直方图相加后 p90 = ${f(approx, 1)}ms(误差 ${f(Math.abs(approx - truth) / truth * 100, 2)}%,桶数 ${merged.buckets.size})`);
log(` 各分片 p90 标量:${perShardP90.map((v) => f(v, 0)).join(', ')}`);
log(` 它们的平均 = ${f(perShardP90.reduce((a, b) => a + b, 0) / 8, 1)}ms → 误差 ${f((perShardP90.reduce((a, b) => a + b, 0) / 8 - truth) / truth * 100, 2)}%`);
log(' → 分位数没有逆运算:先算 p90 再平均 p90 是错的,但直方图可以相加');
log('');
}
/* ---------- 5. Observation cost ---------- */
log('== 5. 采集本身的开销 ==');
{
const N = 20000;
const trial = (fn, reps = 5) => {
let best = Infinity;
for (let r = 0; r < reps; r++) best = Math.min(best, fn());
return best;
};
const measureOnly = () => {
const t0 = performance.now();
for (let i = 0; i < N; i++) {
performance.mark(`c${i}`);
performance.measure(`c${i}`, `c${i}`);
}
const dt = (performance.now() - t0) / N * 1000;
performance.clearMarks();
performance.clearMeasures();
return dt;
};
const marketWithObserver = () => {
let seen = 0;
const obs = new PerformanceObserver((list) => { seen += list.getEntries().length; });
obs.observe({ entryTypes: ['measure'] });
const t0 = performance.now();
for (let i = 0; i < N; i++) {
performance.mark(`o${i}`);
performance.measure(`o${i}`, `o${i}`);
}
const dt = (performance.now() - t0) / N * 1000;
obs.takeRecords();
obs.disconnect();
performance.clearMarks();
performance.clearMeasures();
return dt;
};
// repeat 3 paired rounds, report range instead of single point: single point would treat noise as conclusion
const paired = [];
for (let r = 0; r < 3; r++) paired.push({ noObs: trial(measureOnly), obs: trial(marketWithObserver) });
const withMark = Math.min(...paired.map((x) => x.noObs));
const withObs = Math.min(...paired.map((x) => x.obs));
const marks = paired.map((x) => x.noObs);
const taxes = paired.map((x) => x.obs - x.noObs);
// uncleaned entry buffer accumulates
for (let i = 0; i < 5000; i++) { performance.mark(`z${i}`); performance.measure(`z${i}`, `z${i}`); }
const buffered = performance.getEntriesByType('measure').length;
performance.clearMarks();
performance.clearMeasures();
const pushCost = trial(() => {
const arr = [];
const t0 = performance.now();
for (let i = 0; i < N; i++) arr.push({ name: 'x', value: i, ts: 1 });
return (performance.now() - t0) / N * 1000;
});
log(` performance.mark+measure(无 observer): ${f(Math.min(...marks), 3)}~${f(Math.max(...marks), 3)} µs/次(3 轮 × 5 次取最小)`);
log(` performance.mark+measure(带 observer): ${f(Math.min(...paired.map((x) => x.obs)), 3)}~${f(Math.max(...paired.map((x) => x.obs)), 3)} µs/次`);
log(` → observer 回调额外 ${f(Math.min(...taxes), 3)}~${f(Math.max(...taxes), 3)} µs/次(${f(Math.min(...taxes) / Math.min(...marks) * 100, 0)}%~${f(Math.max(...taxes) / Math.min(...marks) * 100, 0)}%,跨零点 → 落在噪声内)`);
log(` 未清理时 entry buffer 残留 ${buffered} 条 measure(必须 clearMeasures,否则既吃内存又拖慢 getEntries)`);
log(` 直接 push 一个对象进数组: ${f(pushCost, 4)} µs/次(比 mark+measure 便宜 ${f(withMark / pushCost, 0)}x)`);
log(` → 单次采集是 sub-µs 级开销;真正的成本在"每帧都量"的自定义埋点与每帧 flush`);
log('');
}
/* ---------- 6. One flush cost + encoding ---------- */
log('== 6. 一包上报的成本 vs 主线程预算 ==');
{
const rand = mulberry32(53);
const page = typicalPage();
const warm = () => runSession(page);
warm();
const N = 2000;
const t0 = performance.now();
for (let i = 0; i < N; i++) warm();
const t1 = performance.now();
log(` finalize + 编码 + 发送(4 指标): ${f(((t1 - t0) / N) * 1000, 1)} µs/次`);
log(` 占 50ms 长任务预算的 ${f(((t1 - t0) / N) / 50 * 100, 4)}%`);
// encoding cost of 1000 details
const reports = [];
for (let i = 0; i < 1000; i++) {
reports.push({ name: `custom.metric.${i % 9}`, value: rand() * 3000, ts: 1_700_000_000_000 + i });
}
const c0 = performance.now();
const compact = encodeReport(reports);
const c1 = performance.now();
const plain = JSON.stringify({ reports });
const compactBytes = Buffer.byteLength(compact);
const plainBytes = Buffer.byteLength(plain);
const gzCompact = gzipSync(Buffer.from(compact)).length;
const gzPlain = gzipSync(Buffer.from(plain)).length;
log(` 1000 条明细:朴素 JSON ${plainBytes}B / 列式 ${compactBytes}B (${f(compactBytes / plainBytes * 100, 1)}%)`);
log(` gzip 后:${gzPlain}B / ${gzCompact}B → 列式的收益在 gzip 后仍剩 ${f((1 - gzCompact / gzPlain) * 100, 1)}%`);
log(` 编码耗时 ${f((c1 - c0) * 1000, 1)} µs / 1000 条 → 10k 条时 ${f((c1 - c0) * 10, 1)}ms(这已经是一次长任务了)`);
log('');
}
/* ---------- 7. 64KB budget ---------- */
log('== 7. sendBeacon 的 64KB 预算(朴素 JSON vs 列式)==');
{
const mk = (n, withAttr) =>
Array.from({ length: n }, (_, i) => ({
name: `lcp.attribution.${i % 5}`,
value: 1234.5 + i,
ts: 1_700_000_000_000 + i,
...(withAttr ? { el: 'main > div:nth-child(3) > img.hero-img', url: '/static/media/hero.9f3a2b.webp', ev: 'pointerdown' } : {}),
}));
log(' 条数 | 朴素-仅指标 | 朴素-带归因 | 列式-仅指标 | 列式-带归因');
for (const n of [200, 500, 1000, 2000]) {
const a = Buffer.byteLength(JSON.stringify({ reports: mk(n, false) }));
const b = Buffer.byteLength(JSON.stringify({ reports: mk(n, true) }));
const c = Buffer.byteLength(encodeReport(mk(n, false)));
const d = Buffer.byteLength(encodeReport(mk(n, true)));
const flag = (x) => (x > 65536 ? `${String(x).padStart(7)}❌` : `${String(x).padStart(8)} `);
log(` ${String(n).padStart(5)} |${flag(a)} |${flag(b)} |${flag(c)} |${flag(d)}`);
}
log(' → 朴素 JSON 带归因 500 条就爆(83513B);列式同内容 500 条 64581B 刚好卡在上限内、1000 条 129081B 又爆');
log(' → 结论不是"列式更小所以更安全",是"必须按字节算,不能按条数猜";且列式要显式声明可选列,否则归因被静默丢');
log('');
}
/* ---------- 8. Visibility filtering and INP trimming actual impact ---------- */
log('== 8. 两个"看似小事"的口径影响面 ==');
{
const rand = mulberry32(67);
// counting hidden-period shifts → how much CLS inflation: depends on hidden shift density
for (const [label, gapFn] of [
['疏散型(间隔>1s,如推荐位懒加载)', () => 1500 + rand() * 6000],
['密集型(间隔 300~800ms,如轮播/骨架动画)', () => 300 + rand() * 500],
]) {
let inflateSum = 0;
let n = 0;
for (let s = 0; s < 300; s++) {
const shifts = [];
let t = 900;
const visibleCount = 2 + Math.floor(rand() * 5);
for (let i = 0; i < visibleCount; i++) {
t += 300 + rand() * 900;
shifts.push({ value: 0.01 + rand() * 0.07, startTime: t });
}
const hiddenStart = t;
for (let i = 0; i < 20; i++) {
t += gapFn();
shifts.push({ value: 0.01 + rand() * 0.04, startTime: t, hidden: true });
}
const vis = clsSessionWindow(shifts.filter((x) => !x.hidden)).cls;
const all = clsSessionWindow(shifts).cls;
if (vis > 0) { inflateSum += all / vis; n += 1; }
}
log(` 不过滤隐藏期 shift(${label}):CLS 平均放大 ${f(inflateSum / n)}x`);
}
log(' → 会话窗口取最大值这件事,天然免疫"疏散的噪声",但对密集隐藏期 shift 毫无抵抗力');
// INP trimming: more interactions → fit closer to "typical worst"
const rows = [];
for (const total of [10, 30, 60, 200, 1000]) {
let trimSum = 0;
let maxSum = 0;
const runs = 200;
for (let r = 0; r < runs; r++) {
const ints = [];
for (let i = 0; i < total; i++) {
// 95% interactions 40~200ms, 5% 300~500ms jank
ints.push({ latency: rand() < 0.05 ? 300 + rand() * 200 : 40 + rand() * 160 });
}
trimSum += inpFromInteractions(ints).value;
maxSum += inpNaiveMax(ints);
}
rows.push({ total, trim: trimSum / runs, max: maxSum / runs, gap: (maxSum - trimSum) / runs });
}
log(' 交互次数 截尾INP 最差交互 差值');
for (const r of rows) log(` ${String(r.total).padStart(6)} ${f(r.trim, 1).padStart(9)} ${f(r.max, 1).padStart(10)} ${f(r.gap, 1).padStart(8)}`);
log(' → 交互越密,"最差交互"越是噪声;截尾把指标从"运气"变成"稳定可优化"');
log('');
}
/* ---------- 9. TBT vs user feel misalignment ---------- */
log('== 9. TBT 能解释多少"卡" ==');
{
const rand = mulberry32(71);
// same total long-task time, different distribution shapes
const cases = [
{ name: '3 个 200ms 长任务', tasks: [200, 200, 200] },
{ name: '10 个 60ms 长任务', tasks: Array(10).fill(60) },
{ name: '1 个 600ms 长任务', tasks: [600] },
];
for (const c of cases) {
let t = 500;
const entries = c.tasks.map((d) => { const e = { startTime: t, duration: d }; t += d + 100 + rand() * 300; return e; });
const r = tbtFromLongTasks(entries);
const sum = c.tasks.reduce((a, b) => a + b, 0);
log(` ${c.name.padEnd(20)} 总耗时 ${String(sum).padStart(4)}ms → TBT ${String(f(r.tbt, 0)).padStart(4)}ms, 最差 ${f(r.worstBlocking, 0)}ms`);
}
log(' → 同样的 TBT,用户感受完全不同:TBT 是"总账",worstBlocking 才是"体感"');
log('');
}
/* ---------- 10. Session-level sampling p75 stability ---------- */
log('== 10. 采样率 vs p75 稳定性(真实 50000 会话,重复 40 次)==');
{
const rand = mulberry32(83);
const all = Array.from({ length: 50000 }, () => lognormal(rand, 7.15, 0.6));
const truth = exactPercentile(all, 0.75);
log(` 总体 p75 = ${f(truth, 1)}ms`);
for (const rate of [0.01, 0.05, 0.2, 1]) {
const ests = [];
for (let r = 0; r < 40; r++) {
const s = all.filter(() => rand() < rate);
ests.push(exactPercentile(s, 0.75));
}
const mean = ests.reduce((a, b) => a + b, 0) / ests.length;
const sd = Math.sqrt(ests.reduce((a, b) => a + (b - mean) ** 2, 0) / ests.length);
log(` 采样 ${String(rate * 100).padStart(3)}% (≈${String(Math.round(50000 * rate)).padStart(6)} 会话): p75 = ${f(mean, 1)}ms ± ${f(sd, 1)}ms (${f(sd / mean * 100, 2)}% 相对波动)`);
}
log(' → 1% 采样估 p75 的相对波动已在 3% 量级;为了把 3% 压到 1% 要把采样从 1% 抬到 5%(5 倍成本)');
log('');
}
/* ---------- 11. RUM report size and transfer ---------- */
log('== 11. 一包真实报告的体积 ==');
{
const s = typicalPage();
const { sent } = runSession(s);
const payload = sent[0].payload;
log(` 4 指标 + 归因:${Buffer.byteLength(payload)}B(gzip ${gzipSync(Buffer.from(payload)).length}B)`);
log(` 100 万 PV × 2 次 flush/会话 ≈ ${f((Buffer.byteLength(payload) * 2 * 1e6) / 1e6, 0)}MB/天 上行`);
log('');
}
/* ---------- Summary ---------- */
log('== 完成 ==');
const fs = await import('node:fs');
const here = dirname(fileURLToPath(import.meta.url));
fs.writeFileSync(join(here, 'bench-log.txt'), out.join('\n'));
console.log(`\n写入 ${join(here, 'bench-log.txt')}`);
extract-and-test.mjs —— Extract code blocks from article and self-test
// extract-and-test.mjs — extract every js code block from the article, write as .mjs, and run them for real
// Purpose: code in article must prove itself. If any block fails, the article is untrustworthy.
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
// Article is in this directory (dev) or parent (works whether assets and md are separate)
const mdCandidates = [join(here, 'Day34-性能监控.md'), join(here, '..', 'Day34-性能监控.md')];
const md = mdCandidates.find((p) => existsSync(p));
if (!md) { console.error('找不到 Day34-性能监控.md'); process.exit(1); }
const outDir = join(here, 'extracted');
mkdirSync(outDir, { recursive: true });
// Dependencies must be copied alongside
for (const dep of ['vitals.mjs', 'sdk.mjs', 'harness.mjs']) {
copyFileSync(join(here, dep), join(outDir, dep));
}
const src = readFileSync(md, 'utf8');
const blocks = [];
const re = /```(\w+)\n([\s\S]*?)```/g;
let m;
let idx = 0;
while ((m = re.exec(src))) {
idx += 1;
blocks.push({ lang: m[1], code: m[2], line: src.slice(0, m.index).split('\n').length });
}
const js = blocks.filter((b) => b.lang === 'js');
console.log(`正文共 ${blocks.length} 个代码块,其中 js ${js.length} 个,bash ${blocks.filter((b) => b.lang === 'bash').length} 个`);
let pass = 0;
const fails = [];
js.forEach((b, i) => {
const name = `block-${String(i + 1).padStart(2, '0')}.mjs`;
const file = join(outDir, name);
writeFileSync(file, b.code);
try {
const out = execFileSync(process.execPath, [file], { cwd: outDir, encoding: 'utf8', timeout: 60_000 });
const tail = out.trim().split('\n').slice(-1)[0] ?? '';
console.log(` ✅ ${name} (MD 第 ${b.line} 行) → ${tail}`);
pass += 1;
} catch (e) {
const msg = (e.stderr || e.message || '').toString().trim().split('\n').slice(-3).join(' | ');
console.log(` ❌ ${name} (MD 第 ${b.line} 行) → ${msg}`);
fails.push(name);
}
});
// bash blocks also run (only those existing in day34-assets, avoid recursion)
const bash = blocks.filter((b) => b.lang === 'bash');
for (const [i, b] of bash.entries()) {
// Note: one bash block calls extract-and-test.mjs itself; can't re-enter
const cmds = b.code
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#') && !l.startsWith('cd ') && !l.includes('extract-and-test'));
let okAll = true;
for (const c of cmds) {
try {
const cmd = c.replace(/^node (\S+)/, (_, f) => `"${process.execPath}" "${join(here, f)}"`);
execFileSync('/bin/sh', ['-c', cmd], { cwd: here, encoding: 'utf8', timeout: 120_000 });
} catch {
okAll = false;
}
}
if (okAll) { console.log(` ✅ bash 块 ${i + 1} 全部命令通过`); pass += 1; }
else { console.log(` ❌ bash 块 ${i + 1} 有命令失败`); fails.push(`bash-${i + 1}`); }
}
console.log(`\n代码块自证: ${pass} 通过, ${fails.length} 失败`);
process.exit(fails.length ? 1 : 0);