瀏覽器遙測代理與 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 導軌 和 脫敏敏感資料。