浏览器遥测代理与 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 防御仍然很重要。对于匿名、同源测量端点,源验证、严格内容类型和非用户特定的有效负载可能是适当的边界;使用应用程序的安全模型确认该选择。
验证数据
首先部署到非生产环境。确认:
- 浏览器捆绑包不包含 Telemetry 密钥。
- 未知的事件名称、额外字段、原始 URL 和过大的正文将被拒绝。
- 有效的 CLS、INP 和 LCP 事件以数值形式到达。
- 收集请求被阻止或失败不会影响导航。
- 发布和路由值足够稳定,可以进行分组。
- 稀疏路线不用于嘈杂的告警。
使用 按路线和发布配方划分的核心 Web Vitals 在测量旁边保留样本计数。继续使用 使用 SQL 进行前端可靠性监控、JavaScript SDK 导轨 和 脱敏敏感数据。