iT邦幫忙

2026 iThome 鐵人賽

DAY 29
1
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 29

Day 29 — 可觀測性與成本控管

  • 分享至 

  • xImage
  •  

今天要解的問題

網站上線一週了。我不知道三件事:

  1. 有沒有壞? 使用者遇到 JS 錯誤我完全不會知道(他們不會回報,只會關掉分頁)。
  2. 快不快? 我自己的網路很好,Day 19 的 Lighthouse 是在本機跑的。真實使用者的體驗是什麼?
  3. 花多少錢? Day 26 說「約 $0.52/月」——那是我手動去看的。萬一某天暴增呢?

限制:不裝任何第三方分析。 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:20 行,不裝 SDK

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 縮排),而那種錯誤是靜默的。

成本控管

AWS(Day 26 那套)

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 的具體風險

靜態網站唯一真正的成本風險是流量暴增

  • 被大量爬蟲抓(尤其我有 42 MB 的 vendor 檔案)。
  • 被當成免費 CDN 盜連。
  • 惡意的重複下載。

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 不是所有爬蟲都遵守,但主流的會。)

Cloudflare 那套

免費方案沒有明確的成本上限(超出額度是限流不是收費),所以主要監控的是額度用量

/* _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 門檻:好 &lt; 1500ms、需改善 &lt; 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.htmlcourse-map 容器改名,然後手動觸發健檢 → 應該報「200 但內容不對」。確認之後 revert。

小結與明天預告

今天的重點:

  1. 不裝第三方追蹤也能有可觀測性:Web Vitals 用原生 PerformanceObserver(20 行)、錯誤用 window.onerror、傳輸用 sendBeacon
  2. 只存聚合不存個別事件,設保留期限。隱私設計的意義是「連我自己都查不到個別使用者」。
  3. 錯誤上報必須去重 + 限流,否則一個壞迴圈能送出上萬個請求。
  4. RUM 不能取代合成監控:半夜沒人訪問時網站掛了要有人知道。
  5. 健檢要檢查內容不只檢查狀態碼——Day 23 的事故是 200 但公式沒渲染。
  6. 成本告警要用 FORECASTED,在月中就警告而不是月底。閾值設在「異常」不是「剛好超過」。
  7. 真實數據給出的優化方向與我的猜測不同:平均 1.58 s 很好,但 max 4.2 s 揭露了 MathJax 在慢速連線的成本——這是我在自己的網路上永遠看不到的。

明天是最後一天:30 天回顧、量化成果、雷區 Top 10、以及誠實地列出沒做完的事。


上一篇
Day 28 — 跨裝置同步(二):Passkey 登入與資料保護
系列文
知識圖譜 : 技能樹式學習歷程29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言