跳至主要內容
Telemetry
瀏覽說明文件

指南更新於 2026年10月2日閱讀約需 6 分鐘

讓程式設計代理使用這篇文件

開啟 Claude Code、Codex、Cursor 或其他編碼代理的集中提示包,然後將其適應此處介紹的工作流程。

本頁內容
  1. 建築
  2. 定義瀏覽器合約
  3. 在瀏覽器中收集 Web Vitals
  4. 在伺服器上驗證並轉發
  5. 安全和隱私清單
  6. 驗證資料

瀏覽器遙測代理與 Core Web Vitals

Telemetry JavaScript SDK 使用秘密 API 金鑰,屬於可信伺服器程式碼。不要將該金鑰捆綁到瀏覽器應用程式中。要收集 Core Web Vitals 或有界前端可靠性事件,請將一個小的白名單有效負載傳送到您自己的同源端點,並讓該端點將其轉發到 Telemetry。

這種設計將憑證保留在伺服器端,併為應用程式提供了一個地方來強制同意、來源檢查、速率限制、欄位型別、有效負載大小和隱私規則。

建築

browser measurement
  -> POST /api/browser-telemetry
  -> origin, size, rate, and schema checks
  -> server-side telemetry-sh client
  -> browser_performance_measured table

代理不是一般的日誌端點。僅接受已知事件名稱和已知欄位。切勿接受客戶端提供的資料表名稱或 Telemetry API 金鑰。

定義瀏覽器合約

對於 Core Web Vitals,每個指標一個事件很容易驗證:

type BrowserMetric = {
  event_name: "browser_performance_measured";
  metric_name: "CLS" | "INP" | "LCP";
  metric_value: number;
  metric_id: string;
  route_template: string;
  release: string;
  navigation_type: string;
};

使用 /projects/:id 等路由範本,而不是 location.href。請勿傳送查詢字串、DOM 文字、表單值、cookie、授權資料、包含識別符號的引用站點或任意錯誤訊息。隨機指標 ID 可以幫助消除重複重傳,但它不應該成為跨站點使用者識別符號。

在瀏覽器中收集 Web Vitals

web-vitals 包報告當前的 Core Web Vitals。如果您的政策和司法管轄區需要同意,則在同意後傳送:

import { onCLS, onINP, onLCP, type Metric } from "web-vitals";

function reportMetric(metric: Metric) {
  // Adapt these app-owned route/release helpers; never send a raw URL.
  try {
    const body = JSON.stringify({
      event_name: "browser_performance_measured",
      metric_name: metric.name,
      metric_value: metric.value,
      metric_id: metric.id,
      route_template: routeTemplateFor(location.pathname),
      release: window.__APP_RELEASE__,
      navigation_type: metric.navigationType ?? "unknown",
    });

    if (navigator.sendBeacon) {
      try {
        const queued = navigator.sendBeacon(
          "/api/browser-telemetry",
          new Blob([body], { type: "application/json" }),
        );
        if (queued) return;
      } catch {
        // A refused beacon gets one best-effort fallback, not a retry loop.
      }
    }

    void fetch("/api/browser-telemetry", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body,
      keepalive: true,
      credentials: "same-origin",
    }).catch(() => {});
  } catch {
    // Collection or metadata failure must not interrupt the page.
  }
}

onCLS(reportMetric);
onINP(reportMetric);
onLCP(reportMetric);

sendBeacon 適用於頁面終止期間的小型盡力而為有效負載。無法保證交付,瀏覽器擴充套件可能會阻止請求,並且使用者可能會在傳輸之前關閉頁面。將瀏覽器測量結果視為取樣的體驗資料,而不是精確的計費或稽核分類帳。

在伺服器上驗證並轉發

端點應拒絕未知來源、事件名稱、指標名稱、非有限值、超大主體和意外欄位。下面的例子顯示了邊界;使請求和回應原語適應伺服器框架:

import telemetry from "telemetry-sh";

telemetry.init(process.env.TELEMETRY_API_KEY!);
const maxBodyBytes = 4096;
const allowedMetrics = new Set(["CLS", "INP", "LCP"]);
const allowedFields = new Set([
  "event_name", "metric_name", "metric_value", "metric_id",
  "route_template", "release", "navigation_type",
]);
const allowedNavigationTypes = new Set([
  "navigate", "reload", "back-forward", "back-forward-cache",
  "prerender", "restore", "soft-navigation", "unknown",
]);

// Count actual stream bytes: Content-Length can be absent or incorrect.
async function readBoundedJson(request: Request) {
  if (!request.body) return { ok: false as const, status: 400 };
  const reader = request.body.getReader();
  const chunks: Uint8Array[] = [];
  let bytes = 0;
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      bytes += value.byteLength;
      if (bytes > maxBodyBytes) {
        void reader.cancel().catch(() => {});
        return { ok: false as const, status: 413 };
      }
      chunks.push(value);
    }
    const body = new Uint8Array(bytes);
    let offset = 0;
    for (const chunk of chunks) {
      body.set(chunk, offset);
      offset += chunk.byteLength;
    }
    const text = new TextDecoder("utf-8", { fatal: true }).decode(body);
    return { ok: true as const, value: JSON.parse(text) as unknown };
  } catch {
    return { ok: false as const, status: 400 };
  } finally {
    reader.releaseLock();
  }
}

function isBoundedString(value: unknown, max: number): value is string {
  return typeof value === "string" && value.length > 0 && value.length <= max;
}

export async function POST(request: Request) {
  // Supply these app-owned origin, rate-limit and route allowlist adapters.
  if (!isAllowedSameOrigin(request)) {
    return new Response("forbidden", { status: 403 });
  }
  const contentType = request.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
  if (contentType !== "application/json") {
    return new Response("unsupported content type", { status: 415 });
  }
  if (!(await rateLimit(request))) {
    return new Response("rate limited", { status: 429 });
  }
  const parsed = await readBoundedJson(request);
  if (!parsed.ok) return new Response("invalid body", { status: parsed.status });
  const input = parsed.value;
  if (input === null || typeof input !== "object" || Array.isArray(input)) {
    return new Response("invalid event", { status: 400 });
  }
  const event = input as Record<string, unknown>;
  if (
    Object.keys(event).some(key => !allowedFields.has(key)) ||
    event.event_name !== "browser_performance_measured" ||
    typeof event.metric_name !== "string" || !allowedMetrics.has(event.metric_name) ||
    typeof event.metric_value !== "number" || !Number.isFinite(event.metric_value) || event.metric_value < 0 ||
    !isBoundedString(event.metric_id, 80) ||
    !isBoundedString(event.route_template, 160) || !isAllowedRouteTemplate(event.route_template) ||
    !isBoundedString(event.release, 80) ||
    typeof event.navigation_type !== "string" || !allowedNavigationTypes.has(event.navigation_type)
  ) {
    return new Response("invalid event", { status: 400 });
  }
  try {
    await telemetry.log("browser_performance_measured", {
      metric_name: event.metric_name,
      metric_value: event.metric_value,
      metric_id: event.metric_id,
      route_template: event.route_template,
      release: event.release,
      navigation_type: event.navigation_type,
    });
  } catch {
    return new Response("collection unavailable", { status: 502 });
  }
  return new Response(null, { status: 202 });
}

瀏覽器在 beacon 被拒絕時最多發起一次備用請求;進入佇列不等於已送達。請提供應用程式自己的 routeTemplateFor 和版本值,並僅在取得必要同意後註冊收集。伺服器端的 isAllowedSameOrigin、rateLimit 和 isAllowedRouteTemplate 必須由應用程式依自身政策實作。本例限制實際 JSON 位元組數,並拒絕未知欄位,而非靜默截斷。202 僅是此處理函式的確認,不是持久化投遞證明。讀取請求內容和等待 SDK 仍需要應用層時間限制;本例不提供總截止時間或持久化佇列。啟用軟導覽回報前,請檢查 SPA 路由歸屬和指標去重。

在生產中,每個伺服器處理程序初始化一次客戶端,使用有限的上游超時,並決定遙測失敗是否返回 202、204 或可重試的回應。避免瀏覽器重試迴圈。網路和會話邊界的小速率限制可以減少濫用,但不要在事件中儲存原始 IP 地址。

安全和隱私清單

  • 僅將 TELEMETRY_API_KEY 保留在伺服器執行時中。
  • 將端點限制為同源請求以及您期望的方法和內容型別。
  • 在解析 JSON 之前應用較小的正文限制。
  • 允許列表事件名稱、欄位、類別、路由範本和數字範圍。
  • 呼叫上行前限速API。
  • 除非跨源收集是有意且經過審查的產品,否則請勿使用寬鬆的 CORS。
  • 在收集之前應用您的同意和選擇退出規則。
  • 定義任何識別符號的保留和刪除行為。
  • 監控被拒絕和速率限制的請求,而無需複製被拒絕的有效負載。

當端點接受憑據或改變使用者連結狀態時,CSRF 防禦仍然很重要。對於匿名、同源測量端點,源驗證、嚴格內容型別和非使用者特定的有效負載可能是適當的邊界;使用應用程式的安全模型確認該選擇。

驗證資料

首先部署到非生產環境。確認:

  1. 瀏覽器捆綁包不包含 Telemetry 金鑰。
  2. 未知的事件名稱、額外欄位、原始 URL 和過大的正文將被拒絕。
  3. 有效的 CLS、INP 和 LCP 事件以數值形式到達。
  4. 收集請求被阻止或失敗不會影響導覽。
  5. 發布和路由值足夠穩定,可以進行分組。
  6. 稀疏路線不用於嘈雜的警示。

使用 按路線和發布配方劃分的核心 Web Vitals 在測量旁邊保留樣本計數。繼續使用 使用 SQL 進行前端可靠性監控、JavaScript SDK 導軌 和 脫敏敏感資料。

相關功能

記錄穩定的事件名稱、型別明確的欄位,以及經過隱私審查的上下文。

頁面作者與參考資料

Telemetry 編輯團隊負責維護本文;產品團隊審查功能行為、範例和適用範圍。

我們如何審查文件