跳转到内容
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 编辑团队负责维护本文;产品团队审核功能行为、示例和适用范围。

我们如何审核文档