ブラウザ Telemetry プロキシとコア Web Vitals
Telemetry JavaScript SDK は、秘密 API キーを使用し、信頼できるサーバー コードに属します。そのキーをブラウザ アプリケーションにバンドルしないでください。 Core Web Vitals または制限されたフロントエンド信頼性イベントを収集するには、ホワイトリストに登録された小さなペイロードを独自の同一生成元エンドポイントに送信し、そのエンドポイントがそれを Telemetry に転送できるようにします。
この設計では、資格情報がサーバー側に保持され、アプリケーションに同意、送信元チェック、レート制限、フィールド タイプ、ペイロード サイズ、およびプライバシー ルールを適用するための 1 か所が提供されます。
建築
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 の場合、メトリクスごとに 1 つのイベントを簡単に検証できます。
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 が拒否された場合、ブラウザーは代替リクエストを最大1回送ります。キューへの登録は配信完了ではありません。アプリの routeTemplateFor とリリース値を用意し、必要な同意を得てから計測を登録してください。サーバーの isAllowedSameOrigin、rateLimit、isAllowedRouteTemplate はアプリの方針に合わせて実装します。この例は実際の JSON バイト数を制限し、未知のフィールドを切り詰めずに拒否します。202 はこのハンドラーの応答であり、永続的な配信の証明ではありません。本文の読み取りと SDK の待機にはアプリ側の時間制限が必要です。全体の期限や永続キューは提供しません。ソフトナビゲーションの計測を有効にする前に、SPA のルート対応と重複排除を確認してください。
運用環境では、サーバー プロセスごとに 1 回クライアントを初期化し、制限されたアップストリーム タイムアウトを使用し、テレメトリのエラーが返されるかどうかを決定します。 202, 204、または再試行可能な応答。ブラウザーの再試行ループを回避します。ネットワークとセッションの境界による小さなレート制限により悪用を減らすことができますが、イベントに生の IP アドレスを保存しないでください。
セキュリティとプライバシーのチェックリスト
- キープする
TELEMETRY_API_KEYサーバーランタイムのみ。 - エンドポイントを同じオリジンのリクエストと、予想されるメソッドとコンテンツ タイプに制限します。
- JSON を解析する前に、小さな本体制限を適用します。
- ホワイトリストのイベント名、フィールド、カテゴリ、ルート テンプレート、および数値範囲。
- アップストリーム API を呼び出す前のレート制限。
- クロスオリジンコレクションが意図的にレビューされた製品でない限り、寛容な CORS を使用しないでください。
- 収集前に同意およびオプトアウトのルールを適用します。
- 任意の識別子の保持および削除の動作を定義します。
- 拒否されたペイロードをコピーせずに、拒否されたリクエストやレート制限されたリクエストを監視します。
CSRF 防御は、エンドポイントが認証情報を受け入れる場合、またはユーザーにリンクされた状態を変更する場合にも重要です。匿名の同一オリジン測定エンドポイントの場合、オリジン検証、厳密なコンテンツ タイプ、およびユーザー固有ではないペイロードが適切な境界となる可能性があります。その選択をアプリケーションのセキュリティ モデルで確認してください。
データを検証する
最初に非実稼働環境にデプロイします。次のことを確認します。
- ブラウザ バンドルには Telemetry キーが含まれていません。
- 不明なイベント名、余分なフィールド、生の URL、および大きすぎる本文は拒否されます。
- 有効な CLS、INP、および LCP イベントは数値とともに到着します。
- 収集リクエストがブロックまたは失敗しても、ナビゲーションには影響しません。
- リリースとルートの値はグループ化できるほど安定しています。
- スパースルートはノイズの多いアラートには使用されません。
を使用します。 ルートおよびリリースレシピ別のコアウェブバイタル 測定値の横にサンプル数を保存します。続けて SQL によるフロントエンド信頼性モニタリング、 JavaScript SDK ガイド、そして 機密データの編集.