Skip to content
Telemetry
Browse docs

GuidesUpdated October 2, 20266 min read

Use this doc with your coding agent

Copy an instrumentation prompt into your coding agent and adapt it to your application.

On this page
  1. Architecture
  2. Define the browser contract
  3. Collect Web Vitals in the browser
  4. Validate and forward on the server
  5. Security and privacy checklist
  6. Verify the data

Browser Telemetry proxy and Core Web Vitals

The Telemetry JavaScript SDK uses a secret API key and belongs in trusted server code. Do not bundle that key into a browser application. To collect Core Web Vitals or bounded frontend reliability events, send a small allowlisted payload to your own same-origin endpoint and let that endpoint forward it to Telemetry.

This design keeps credentials server-side and gives the application one place to enforce consent, origin checks, rate limits, field types, payload size, and privacy rules.

Architecture

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

The proxy is not a general log endpoint. Accept only known event names and known fields. Never accept a client-supplied table name or Telemetry API key.

Define the browser contract

For Core Web Vitals, one event per metric is easy to validate:

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;
};

Use a route template such as /projects/:id, not location.href. Do not send query strings, DOM text, form values, cookies, authorization data, referrers containing identifiers, or arbitrary error messages. A random metric ID can help deduplicate retransmission, but it should not become a cross-site user identifier.

Collect Web Vitals in the browser

The web-vitals package reports the current Core Web Vitals. Send after consent where consent is required by your policy and jurisdiction:

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 is appropriate for small best-effort payloads during page termination. Delivery is not guaranteed, browser extensions may block the request, and a user may close the page before transmission. Treat browser measurements as sampled experience data, not an exact billing or audit ledger.

Validate and forward on the server

The endpoint should reject unknown origins, event names, metric names, non-finite values, oversized bodies, and unexpected fields. The example below shows the boundary; adapt the request and response primitives to the server framework:

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 });
}

The browser makes at most one fallback request when a beacon is refused; queued does not mean delivered. Supply routeTemplateFor and the release value from your application, and register collection only after any required consent. On the server, supply isAllowedSameOrigin, rateLimit, and isAllowedRouteTemplate with your own policy. The example bounds actual JSON bytes and rejects unknown fields instead of silently trimming them. A 202 is this handler’s acknowledgment, not durable delivery proof. Body reads and the awaited SDK call still require application-level time limits; no total deadline or durable queue is provided. Review SPA route attribution and metric deduplication before enabling soft-navigation reporting.

In production, initialize the client once per server process, use a bounded upstream timeout, and decide whether telemetry failures return 202, 204, or a retryable response. Avoid browser retry loops. A small rate limit by network and session boundary can reduce abuse, but do not store a raw IP address in the event.

Security and privacy checklist

  • Keep TELEMETRY_API_KEY in the server runtime only.
  • Restrict the endpoint to same-origin requests and the methods and content types you expect.
  • Apply a small body limit before parsing JSON.
  • Allowlist event names, fields, categories, route templates, and numeric ranges.
  • Rate-limit before calling the upstream API.
  • Do not use permissive CORS unless cross-origin collection is an intentional, reviewed product.
  • Apply your consent and opt-out rules before collection.
  • Define retention and deletion behavior for any identifier.
  • Monitor rejected and rate-limited requests without copying rejected payloads.

CSRF defenses still matter when the endpoint accepts credentials or mutates user-linked state. For an anonymous, same-origin measurement endpoint, origin validation, strict content types, and a non-user-specific payload may be the appropriate boundary; confirm that choice with the application's security model.

Verify the data

Deploy first to a non-production environment. Confirm that:

  1. The browser bundle contains no Telemetry key.
  2. Unknown event names, extra fields, raw URLs, and oversized bodies are rejected.
  3. Valid CLS, INP, and LCP events arrive with numeric values.
  4. A blocked or failed collection request does not affect navigation.
  5. Release and route values are stable enough to group.
  6. Sparse routes are not used for noisy alerts.

Use the Core Web Vitals by route and release recipe to keep sample count beside the measurements. Continue with Frontend Reliability Monitoring with SQL, the JavaScript SDK guide, and Redacting Sensitive Data.

Related feature

Use consistent event names and field types. Check for private data before sending events.

Page authors and references

The Telemetry editorial team maintains this page. The product team checks the examples and confirms how the product behaves.

How we review our docs