網站上線一週了。我不知道三件事:
限制:不裝任何第三方分析。 Day 25 我在 Permissions-Policy 明確關掉 interest-cohort,也在 CSP 把 connect-src 鎖成 'self'。裝 GA 就是自己打自己的臉。
所以今天全部自己做。
先劃線:
| 收集 | 不收集 |
|---|---|
| Web Vitals(LCP/INP/CLS) | IP 位址 |
| JS 錯誤(訊息 + 堆疊) | 使用者 id(連 Day 28 的 UUID 都不送) |
頁面類型(chapter / index) |
完整 URL(含 ?ch=ch09 會洩漏在讀什麼) |
連線類型(4g / wifi) |
User agent 全文 |
| CSP 違規 | 任何 cookie |
「頁面類型」而不是「完整 URL」 是刻意的。Day 25 我設了 Referrer-Policy: strict-origin-when-cross-origin 就是為了不洩漏使用者在讀哪一課;自己的遙測當然不能反過來收集它。
const pageType = () => {
const p = location.pathname.split("/").pop() || "index.html";
return p.replace(/\.html$/, ""); // "chapter" / "index" / "course" / "dashboard"
};
代價:我無法知道「哪一課最多人讀」。我接受——那個資訊對我的決策沒那麼重要,而它對使用者的隱私成本很高。
web-vitals 套件約 2 KB gzip,但我連那個都不要(要嘛進 vendor/、要嘛違反 CSP)。原生 API 夠用:
/* js/telemetry.js */
const Telemetry = {
ENDPOINT: "/api/beacon",
buf: [],
MAX: 20,
init() {
if (!("PerformanceObserver" in window)) return;
this.observeLCP();
this.observeINP();
this.observeCLS();
this.observeErrors();
/* 分頁隱藏時送出——比 unload 可靠(Day 27 學到的) */
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.flush();
});
},
push(name, value, extra) {
this.buf.push({
n: name,
v: Math.round(value),
p: pageType(),
c: navigator.connection?.effectiveType || "?", // "4g" / "3g" / "wifi"
...extra,
});
if (this.buf.length >= this.MAX) this.flush();
},
flush() {
if (!this.buf.length) return;
const payload = JSON.stringify({ t: Date.now(), items: this.buf });
this.buf = [];
try {
/* sendBeacon 不會被頁面卸載中斷,也不阻塞導覽 */
navigator.sendBeacon(this.ENDPOINT, new Blob([payload], { type: "application/json" }));
} catch { /* 遙測失敗絕不影響使用者 */ }
},
observeLCP() {
let last = 0;
const po = new PerformanceObserver(list => {
for (const e of list.getEntries()) last = e.startTime;
});
po.observe({ type: "largest-contentful-paint", buffered: true });
/* LCP 在頁面隱藏時定案 */
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && last) {
po.takeRecords(); this.push("lcp", last); last = 0;
}
}, { once: true });
},
observeINP() {
/* 近似 INP:取最長的互動延遲(真 INP 是 P98,這裡簡化) */
let worst = 0;
const po = new PerformanceObserver(list => {
for (const e of list.getEntries()) {
const d = e.processingEnd - e.startTime;
if (d > worst) worst = d;
}
});
try { po.observe({ type: "event", durationThreshold: 40, buffered: true }); }
catch { return; } // 舊瀏覽器不支援就算了
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && worst) { this.push("inp", worst); worst = 0; }
}, { once: true });
},
observeCLS() {
let cls = 0;
const po = new PerformanceObserver(list => {
for (const e of list.getEntries()) if (!e.hadRecentInput) cls += e.value;
});
po.observe({ type: "layout-shift", buffered: true });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.push("cls", cls * 1000); // 存千分位整數
}, { once: true });
},
};
buffered: true 很重要——它讓 observer 拿到註冊之前就已經發生的事件。少了它,LCP 幾乎永遠收不到(LCP 通常在 telemetry.js 執行前就發生了)。
CLS 乘 1000 存成整數:CLS 的合格線是 0.1,四位小數的浮點數在後端聚合時容易出精度問題,整數乾淨。
observeErrors() {
const seen = new Set();
const report = (kind, msg, extra = {}) => {
/* 同一個錯誤只送一次——壞掉的迴圈會噴幾千次 */
const sig = `${kind}:${msg}`.slice(0, 200);
if (seen.has(sig)) return;
seen.add(sig);
if (seen.size > 10) return; // 單頁上限 10 種錯誤
this.push("err", 0, { k: kind, m: String(msg).slice(0, 300), ...extra });
this.flush(); // 錯誤立刻送,不等 buffer 滿
};
window.addEventListener("error", e => {
if (e.target !== window) { // 資源載入失敗(img/script/link)
const src = (e.target.src || e.target.href || "").replace(location.origin, "");
return report("res", src);
}
report("js", e.message, {
f: (e.filename || "").replace(location.origin, ""),
l: e.lineno,
});
}, true); // capture: true 才抓得到資源錯誤
window.addEventListener("unhandledrejection", e =>
report("rej", e.reason?.message || e.reason));
},
三層防洪:同一簽章只送一次、單頁最多 10 種、訊息截斷 300 字。
沒有這些的話:一個在 requestAnimationFrame 裡的錯誤會每秒噴 60 次,一個使用者能在五分鐘內送出 18000 個請求。這不是理論——Day 18 的動畫我就寫壞過一次計時器。
capture: true 是抓資源載入錯誤的唯一方式(error 事件在資源上不冒泡)。這讓我能發現「某個 CDN 的字型 404」這類問題——雖然 Day 16 之後我已經沒有第三方資源了,但它能抓到部署漏檔(Day 21 那個大小寫問題的線上版)。
/* functions/api/beacon.js */
export async function onRequestPost({ request, env }) {
/* 大小上限:防有人拿這個端點當免費儲存 */
const len = Number(request.headers.get("content-length") || 0);
if (len > 8192) return new Response(null, { status: 413 });
let body;
try { body = await request.json(); } catch { return new Response(null, { status: 204 }); }
const items = Array.isArray(body?.items) ? body.items.slice(0, 30) : [];
const bucket = Math.floor(Date.now() / 3600_000); // 小時級時間桶
for (const it of items) {
/* 白名單指標名,其他丟掉 */
if (!["lcp", "inp", "cls", "err"].includes(it.n)) continue;
if (it.n === "err") {
/* 錯誤寫進 log,交給 Workers Logs(可用 wrangler tail 看) */
console.log(JSON.stringify({
t: "err", k: it.k, m: String(it.m).slice(0, 300),
f: it.f, l: it.l, p: it.p,
}));
continue;
}
/* 效能指標:只存聚合,不存個別事件 */
const key = `m:${bucket}:${it.p}:${it.n}:${it.c}`;
const cur = await env.KV.get(key, "json") || { n: 0, sum: 0, max: 0 };
cur.n++; cur.sum += Number(it.v) || 0;
cur.max = Math.max(cur.max, Number(it.v) || 0);
await env.KV.put(key, JSON.stringify(cur), { expirationTtl: 40 * 86400 });
}
return new Response(null, { status: 204 }); // 永遠 204,不回內容
}
只存聚合,不存個別事件。 KV 裡是 {count, sum, max},不是每一筆的原始值。這樣:
expirationTtl: 40 * 86400:40 天自動過期。保留期限要主動設定,不然資料會無限累積(成本 + 責任)。
一個實務決定:CSP 違規報告(Day 25)與這個端點分開。理由是 CSP 報告的雜訊量級完全不同(擴充套件會產生大量誤報),混在一起會淹掉真訊號。
/* functions/api/stats.js — 需要簡易保護 */
export async function onRequestGet({ request, env }) {
const key = new URL(request.url).searchParams.get("k");
if (key !== env.STATS_KEY) return new Response("nope", { status: 404 }); // 404 不是 401
const now = Math.floor(Date.now() / 3600_000);
const out = {};
for (let h = 0; h < 24 * 7; h++) {
const list = await env.KV.list({ prefix: `m:${now - h}:` });
for (const k of list.keys) {
const [, , page, metric, conn] = k.name.split(":");
const v = await env.KV.get(k.name, "json");
const id = `${page}/${metric}/${conn}`;
out[id] = out[id] || { n: 0, sum: 0, max: 0 };
out[id].n += v.n; out[id].sum += v.sum; out[id].max = Math.max(out[id].max, v.max);
}
}
const rows = Object.entries(out).map(([id, v]) => ({
id, count: v.n, avg: Math.round(v.sum / v.n), max: v.max,
})).sort((a, b) => b.count - a.count);
return new Response(JSON.stringify(rows, null, 2),
{ headers: { "content-type": "application/json", "cache-control": "no-store" } });
}
回 404 而不是 401:不存在的端點不會告訴攻擊者「這裡有東西但你沒權限」。
第一週實測數據:
| 頁面/指標/連線 | 樣本 | 平均 | 最大 |
|---|---|---|---|
| chapter/lcp/4g | 218 | 1580 ms | 4210 ms |
| chapter/lcp/wifi | 143 | 720 ms | 1900 ms |
| index/lcp/4g | 96 | 890 ms | 2100 ms |
| chapter/inp/4g | 201 | 64 ms | 380 ms |
| chapter/cls/4g | 218 | 12(=0.012) | 89 |
收穫:chapter/lcp/4g 平均 1.58 s 與 Day 19 的 Lighthouse 模擬(1.6 s)幾乎一致——模擬是準的。但 max 4.2 s 是我完全不知道的:有些使用者的體驗比平均差三倍。追下去發現是 MathJax 首次載入(1.1 MB)在慢速連線上的成本。
這給了下一步的方向:課文頁的公式排版可以延後——先渲染文字(LCP 就完成了),MathJax 用 requestIdleCallback 觸發。這個優化沒有真實數據我不會想到,因為我自己的網路上看不出差別。
wrangler tail 看一週:
npx wrangler pages deployment tail --project-name=learnpath --format=pretty | grep '"t":"err"'
| 錯誤 | 次數 | 判斷 |
|---|---|---|
res: /js/data/search-index.js |
3 | 真 bug:Day 12 的動態載入在慢速連線 timeout,沒有重試 |
js: Cannot read properties of null (reading 'getContext') |
2 | 真 bug:Day 18 的動畫在換課競態下 canvas 已被移除 |
rej: The operation is aborted |
47 | 使用者離開頁面時的 fetch 中斷,非 bug,加進忽略清單 |
js: ResizeObserver loop completed... |
12 | 瀏覽器已知噪音,非 bug |
兩個真 bug,都不會有人回報。 第一個表現為「搜尋沒反應」,第二個表現為「動畫沒出來」——使用者只會覺得「這網站怪怪的」然後離開。
修法:
/* 1. 搜尋索引載入加重試 */
loadIndex(retry = 2) {
return this._load().catch(e => {
if (retry > 0) return new Promise(r => setTimeout(r, 800)).then(() => this.loadIndex(retry - 1));
throw e;
});
}
/* 2. 動畫的 canvas 檢查(Day 18 的清理機制還不夠) */
const step = () => {
const ctx = el.querySelector(".anim-img")?.getContext("2d");
if (!ctx || !el.isConnected) return stop(); // 元素已離開 DOM
/* … */
};
順手把噪音加進忽略清單:
const IGNORE = [/ResizeObserver loop/, /operation is aborted/, /Load failed$/];
if (IGNORE.some(re => re.test(String(msg)))) return;
忽略清單要克制。 每加一條都要確認它真的是噪音,不是「我不想面對的 bug」。
真實使用者遙測(RUM)只在有人訪問時才有資料。半夜網站掛掉、沒人訪問、我完全不知道。
所以加一個定時健檢。用 Cloudflare Cron Triggers(免費):
/* functions/_scheduled.js — 每 15 分鐘 */
export default {
async scheduled(event, env) {
const targets = [
"https://learnpath.example.com/index.html",
"https://learnpath.example.com/chapter.html?ch=ch09",
"https://aws.learnpath.example.com/index.html", // 第二套也要監控
];
const fails = [];
for (const url of targets) {
try {
const t0 = Date.now();
const res = await fetch(url, { cf: { cacheTtl: 0 } });
const ms = Date.now() - t0;
const html = await res.text();
if (!res.ok) fails.push(`${url} → HTTP ${res.status}`);
/* 內容檢查:不只看 200,要確認關鍵內容在 */
else if (!html.includes("course-map") && !html.includes("lesson-body"))
fails.push(`${url} → 200 但內容不對`);
else if (ms > 3000) fails.push(`${url} → 慢 ${ms}ms`);
/* 安全 header 也要持續監控(設定可能被誤改) */
const csp = res.headers.get("content-security-policy") || "";
if (!csp.includes("frame-ancestors")) fails.push(`${url} → CSP 缺 frame-ancestors`);
} catch (e) {
fails.push(`${url} → ${e.message}`);
}
}
if (fails.length) await notify(env, `🚨 健檢失敗\n${fails.join("\n")}`);
},
};
async function notify(env, text) {
if (!env.ALERT_WEBHOOK) return;
await fetch(env.ALERT_WEBHOOK, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
}
「200 但內容不對」這條檢查比狀態碼重要。 Day 23 那次 CSP 事故就是典型:HTTP 200、HTML 完整、但公式沒渲染。狀態碼監控完全看不到。
持續監控安全 header 也值得——設定會被誤改(我在 Day 25 就寫錯過 _headers 縮排),而那種錯誤是靜默的。
resource "aws_budgets_budget" "monthly" {
name = "learnpath-monthly"
budget_type = "COST"
limit_amount = "5"
limit_unit = "USD"
time_unit = "MONTHLY"
# 三段告警:預測、實際 80%、實際 100%
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_email_addresses = [var.alert_email]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = [var.alert_email]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = [var.alert_email]
}
}
FORECASTED 那條最有價值:它在月中就會警告「照這個速度會超支」,而不是月底才發現。
$5 的上限是預期值($0.52)的十倍。告警閾值要設在「異常」而不是「剛好超過」——設太緊會被雜訊淹沒然後開始忽略它。
靜態網站唯一真正的成本風險是流量暴增:
CloudFront 前 1 TB 免費,之後約 $0.085/GB。要跑到 1 TB 需要下載我的整站兩萬四千次——不太可能,但值得設防:
# 對 vendor/ 加 WAF rate limit(可選,WAF 本身有月費 $5,超過我的預算)
# 更便宜的做法:CloudFront 的 Response Headers Policy 加 X-Robots-Tag
我選最便宜的做法——robots.txt 加上明確規則,並且接受爬蟲的成本:
User-agent: *
Allow: /
Disallow: /api/
Crawl-delay: 2
# vendor 是函式庫,不需要被索引
Disallow: /vendor/
(Crawl-delay 不是所有爬蟲都遵守,但主流的會。)
免費方案沒有明確的成本上限(超出額度是限流不是收費),所以主要監控的是額度用量:
/* _scheduled.js 加一段:每天記錄 Workers 請求數 */
const usage = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT}/workers/scripts`,
{ headers: { Authorization: `Bearer ${env.CF_READ_TOKEN}` } });
實測第一週:Workers 請求約 3400/天(免費額度 10 萬/天),D1 讀取約 8000/天(免費 500 萬/天)。距離額度還有 30 倍空間,不需要擔心。
/* status.html:把上面的資料畫成圖(沿用 Day 17 的手寫 SVG) */
const rows = await (await fetch(`/api/stats?k=${key}`)).json();
root.innerHTML = `
<h2>近 7 天效能</h2>
${rows.filter(r => r.id.includes("/lcp/")).map(r => `
<div class="st-row">
<span class="st-id">${esc(r.id)}</span>
<span class="st-bar"><i style="width:${Math.min(100, r.avg / 40)}%"
class="${r.avg > 2500 ? "bad" : r.avg > 1500 ? "warn" : "ok"}"></i></span>
<span class="st-val">avg ${r.avg}ms / max ${r.max}ms(n=${r.count})</span>
</div>`).join("")}
<p class="st-note">LCP 門檻:好 < 1500ms、需改善 < 2500ms</p>`;
遙測本身不能拖慢網站。 第一版我用 fetch 送遙測,在 visibilitychange 時發——結果部分請求被頁面卸載中斷,資料遺失約三成。改用 sendBeacon 之後完整。
而且遙測程式碼要整段包在 try/catch 裡:
try { Telemetry.init(); } catch {}
Day 9 的「附加功能防禦式接入」原則在這裡是硬性要求——監控系統掛掉不能連帶弄壞被監控的系統。
buffered: true 漏了就收不到 LCP。 我第一天的數據裡 LCP 樣本數是 0,以為是 API 壞了。實際上 LCP 在 telemetry.js 執行前就發生了,沒有 buffered 的 observer 拿不到歷史事件。
KV 的讀後寫有 race condition。 我的聚合是「讀 → 加 → 寫」,兩個同時進來的請求會互相覆蓋。對統計數據來說掉幾筆無所謂(我不需要精確計數),但要知道自己在做這個取捨。如果需要精確就得用 Durable Objects 或 D1 的原子 UPDATE(像 Day 27 那樣)。
忽略清單會掩蓋真 bug。 我一度想把 Load failed 加進忽略(它在 Safari 上很常見),後來發現其中有幾筆是真的資源 404。改成「先看樣本再決定」:對每種錯誤先觀察一週、看它是否集中在特定頁面或特定連線類型,再決定是噪音還是 bug。
別對 CSP 違規設告警。 我試過一天,收到 200 多封(全部是瀏覽器擴充套件)。改成每週人工看一次。
# 遙測端點的防護
curl -X POST https://learnpath.example.com/api/beacon \
-H 'content-type: application/json' \
-d "{\"items\":[{\"n\":\"evil\",\"v\":1}]}" -w '\n%{http_code}\n'
# 204,且白名單外的指標名不會被寫入
# 超大 payload
head -c 20000 /dev/urandom | base64 | curl -X POST https://learnpath.example.com/api/beacon \
--data-binary @- -w '\n%{http_code}\n'
# 413
# 看板需要 key
curl -s https://learnpath.example.com/api/stats -w '\n%{http_code}\n' # 404
# 手動觸發健檢
npx wrangler pages deployment tail --project-name=learnpath &
# 等 cron 觸發,或本機跑 wrangler dev --test-scheduled
刻意製造問題,確認監控會抓到:
// 1. 在 console 丟一個錯誤
setTimeout(() => { null.foo(); }, 100);
// 預期:wrangler tail 出現 err 記錄
// 2. 同一個錯誤丟 100 次
for (let i = 0; i < 100; i++) setTimeout(() => { null.foo(); }, i);
// 預期:只送一次(去重生效)
健檢的驗證比較麻煩(要真的弄壞線上網站)。我的做法是暫時改一個 preview 部署:把 index.html 的 course-map 容器改名,然後手動觸發健檢 → 應該報「200 但內容不對」。確認之後 revert。
今天的重點:
PerformanceObserver(20 行)、錯誤用 window.onerror、傳輸用 sendBeacon。明天是最後一天:30 天回顧、量化成果、雷區 Top 10、以及誠實地列出沒做完的事。
iThome鐵人賽