Web Engineering ·

Frontend Observability in Production: Real User Monitoring, Error Tracking, and Performance Instrumentation with OpenTelemetry

Most teams have solid backend observability but no visibility into what users actually experience in the browser. This guide covers Real User Monitoring with Core Web Vitals, client-side error tracking with source map resolution, distributed tracing via OpenTelemetry JS SDK, and a build-vs-buy comparison for frontend observability tooling.

Frontend Observability in Production: Real User Monitoring, Error Tracking, and Performance Instrumentation with OpenTelemetry

Your backend is fully instrumented. Traces flow through every service, spans are correlated across queues and databases, and your on-call rotation has dashboards that light up within seconds of an anomaly. Then a user files a support ticket saying the checkout button does nothing on Safari 17, and you have no idea when it started or how many transactions it silently ate.

This is the frontend observability gap. Most teams have closed the backend side of the problem and left the browser completely dark. The frontend is where users actually experience your product, and it is also the environment with the most failure modes you cannot reproduce in staging: network conditions, browser extensions, device memory pressure, carrier-grade NAT, third-party scripts blocking the main thread.

This article covers how to close that gap properly: Real User Monitoring (RUM) for Core Web Vitals, client-side error tracking with source map resolution, frontend distributed tracing using the OpenTelemetry JS SDK to propagate trace context across the network boundary, and custom performance marks for business-critical flows. At the end there is a tradeoffs table comparing the three main approaches to building this stack and a set of production considerations you will hit when you try to ship it.

Real User Monitoring: Collecting Core Web Vitals

Synthetic monitoring runs Lighthouse in a data center. RUM measures what real users experience on their actual devices and networks. The two are complementary, but if you have to choose one, RUM wins because it surfaces the long tail: the user on a throttled 4G connection loading your 900 KB JavaScript bundle.

The Web Vitals library from Google is the starting point. It gives you Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS), Interaction to Next Paint (INP), First Contentful Paint (FCP), and Time to First Byte (TTFB) with a consistent API across browser quirks.

import { onLCP, onCLS, onINP, onFCP, onTTFB } from "web-vitals";

interface VitalPayload {
  name: string;
  value: number;
  rating: "good" | "needs-improvement" | "poor";
  delta: number;
  id: string;
  navigationType: string;
  url: string;
  userAgent: string;
  sessionId: string;
  timestamp: number;
}

function buildPayload(metric: {
  name: string;
  value: number;
  rating: "good" | "needs-improvement" | "poor";
  delta: number;
  id: string;
  navigationType: string;
}): VitalPayload {
  return {
    ...metric,
    url: location.href,
    userAgent: navigator.userAgent,
    sessionId: getSessionId(),
    timestamp: Date.now(),
  };
}

function sendVital(payload: VitalPayload): void {
  // Use sendBeacon so the request survives page unload
  const body = JSON.stringify(payload);
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      method: "POST",
      body,
      keepalive: true,
    }).catch(() => {
      // Silently drop; vitals are best-effort
    });
  }
}

onLCP((metric) => sendVital(buildPayload(metric)));
onCLS((metric) => sendVital(buildPayload(metric)));
onINP((metric) => sendVital(buildPayload(metric)));
onFCP((metric) => sendVital(buildPayload(metric)));
onTTFB((metric) => sendVital(buildPayload(metric)));

sendBeacon is critical here. LCP and CLS are reported at page unload or visibility change, and a normal fetch will be killed mid-flight. sendBeacon queues the request in the browser and sends it even after the page tears down.

The session ID lets you correlate multiple vitals from the same page visit. A simple implementation:

function getSessionId(): string {
  const key = "__rum_sid";
  let sid = sessionStorage.getItem(key);
  if (!sid) {
    sid = crypto.randomUUID();
    sessionStorage.setItem(key, sid);
  }
  return sid;
}

On the receiving end, /api/vitals should fan out to a time-series store (ClickHouse or TimescaleDB both work well for high-volume metric ingest) so you can percentile your Core Web Vitals by URL, browser family, connection type, and geographic region.

Client-Side Error Tracking with Source Map Resolution

Your production JavaScript is minified. When window.onerror fires and you get Uncaught TypeError: Cannot read properties of undefined (reading 'map') at line 1, column 87342 in main.abc123.js, that is not actionable without a source map.

The first layer is capturing errors reliably:

interface CapturedError {
  message: string;
  stack: string | undefined;
  url: string;
  lineNumber: number;
  columnNumber: number;
  sessionId: string;
  timestamp: number;
  breadcrumbs: Breadcrumb[];
}

interface Breadcrumb {
  type: "navigation" | "click" | "fetch" | "console";
  message: string;
  timestamp: number;
}

const breadcrumbs: Breadcrumb[] = [];
const MAX_BREADCRUMBS = 20;

function addBreadcrumb(crumb: Breadcrumb): void {
  breadcrumbs.push(crumb);
  if (breadcrumbs.length > MAX_BREADCRUMBS) {
    breadcrumbs.shift();
  }
}

// Instrument navigation
const originalPushState = history.pushState.bind(history);
history.pushState = (...args) => {
  addBreadcrumb({
    type: "navigation",
    message: args[2] as string,
    timestamp: Date.now(),
  });
  return originalPushState(...args);
};

// Instrument fetch for breadcrumbs (not full response body)
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
  const url = typeof input === "string" ? input : input.url;
  const response = await originalFetch(input, init);
  addBreadcrumb({
    type: "fetch",
    message: `${init?.method ?? "GET"} ${url} ${response.status}`,
    timestamp: Date.now(),
  });
  return response;
};

window.addEventListener("error", (event) => {
  const payload: CapturedError = {
    message: event.message,
    stack: event.error?.stack,
    url: event.filename,
    lineNumber: event.lineno,
    columnNumber: event.colno,
    sessionId: getSessionId(),
    timestamp: Date.now(),
    breadcrumbs: [...breadcrumbs],
  };
  sendError(payload);
});

window.addEventListener("unhandledrejection", (event) => {
  const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
  const payload: CapturedError = {
    message: error.message,
    stack: error.stack,
    url: location.href,
    lineNumber: 0,
    columnNumber: 0,
    sessionId: getSessionId(),
    timestamp: Date.now(),
    breadcrumbs: [...breadcrumbs],
  };
  sendError(payload);
});

Source map resolution happens server-side, not in the browser. Do not ship source maps to your CDN. Instead, upload them to your error tracking ingest service during your build pipeline:

// In your CI pipeline, after build
import { SourceMapConsumer } from "source-map";
import * as fs from "fs/promises";
import * as path from "path";

interface ResolvedFrame {
  source: string;
  line: number;
  column: number;
  name: string | null;
}

async function resolveFrame(
  minifiedFile: string,
  line: number,
  column: number,
  sourceMapDir: string
): Promise<ResolvedFrame | null> {
  const mapPath = path.join(sourceMapDir, `${path.basename(minifiedFile)}.map`);
  const rawMap = await fs.readFile(mapPath, "utf-8");
  const consumer = await new SourceMapConsumer(rawMap);

  const position = consumer.originalPositionFor({ line, column });
  consumer.destroy();

  if (!position.source) return null;

  return {
    source: position.source,
    line: position.line ?? 0,
    column: position.column ?? 0,
    name: position.name,
  };
}

The ingest pipeline stores the raw minified stack on arrival and resolves it asynchronously. This keeps ingest latency low and lets you re-resolve frames if you upload a corrected source map after a bad deploy.

Frontend Distributed Tracing with OpenTelemetry

The gap between frontend error tracking and backend observability is the network boundary. A user action triggers a fetch, that fetch calls three services, one service calls a database, and somewhere in that chain something is slow or broken. Without trace context propagation, you are correlating events manually by timestamp.

The OpenTelemetry JS SDK lets you start a trace in the browser and propagate the traceparent header to your backend, where your existing backend instrumentation picks it up and continues the same trace.

import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { W3CTraceContextPropagator } from "@opentelemetry/core";
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { trace, context, SpanStatusCode } from "@opentelemetry/api";
import { Resource } from "@opentelemetry/resources";
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";

const resource = new Resource({
  [SEMRESATTRS_SERVICE_NAME]: "web-frontend",
  [SEMRESATTRS_SERVICE_VERSION]: import.meta.env.VITE_APP_VERSION ?? "unknown",
});

const exporter = new OTLPTraceExporter({
  url: "/api/otel/traces", // Proxy through your own backend to avoid CORS and to scrub PII
});

const provider = new WebTracerProvider({
  resource,
  spanProcessors: [
    new BatchSpanProcessor(exporter, {
      maxQueueSize: 100,
      maxExportBatchSize: 10,
      scheduledDelayMillis: 2000,
    }),
  ],
});

provider.register({
  contextManager: new ZoneContextManager(),
  propagator: new W3CTraceContextPropagator(),
});

Now instrument user-initiated fetches so spans are created and the traceparent header is injected automatically:

import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
import { registerInstrumentations } from "@opentelemetry/instrumentation";

registerInstrumentations({
  instrumentations: [
    new FetchInstrumentation({
      propagateTraceHeaderCorsUrls: [
        /https:\/\/api\.yourapp\.com\/.*/,
      ],
      clearTimingResources: true,
    }),
  ],
});

For business-critical flows that span multiple async steps, create explicit spans rather than relying on auto-instrumentation:

const tracer = trace.getTracer("checkout-flow");

async function initiateCheckout(cartId: string): Promise<{ orderId: string }> {
  return tracer.startActiveSpan("checkout.initiate", async (span) => {
    span.setAttributes({
      "cart.id": cartId,
      "cart.item_count": getCartItemCount(),
    });

    try {
      const response = await fetch(`/api/checkout`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ cartId }),
      });

      if (!response.ok) {
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: `HTTP ${response.status}`,
        });
        throw new Error(`Checkout failed: ${response.status}`);
      }

      const result = await response.json();
      span.setAttributes({ "order.id": result.orderId });
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (error) {
      span.recordException(error as Error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw error;
    } finally {
      span.end();
    }
  });
}

The span flows to your backend. Your backend OpenTelemetry instrumentation (Node.js, Go, whatever) picks up the traceparent from the HTTP header and creates child spans under the same trace. In Jaeger or Tempo you now see the full trace: browser interaction, network hop, API handler, database query.

Custom Performance Marks for Business-Critical Flows

Core Web Vitals measure page-level performance. But “time to interactive” is not the same as “time until the user can see their order history” or “time until the checkout form is fully ready.” Use the Performance Observer API with custom marks to measure what matters to your business.

interface FlowMeasurement {
  flow: string;
  duration: number;
  url: string;
  sessionId: string;
  timestamp: number;
}

function markFlowStart(flowName: string): void {
  performance.mark(`flow:${flowName}:start`);
}

function markFlowEnd(flowName: string): void {
  const startMark = `flow:${flowName}:start`;
  const endMark = `flow:${flowName}:end`;
  const measureName = `flow:${flowName}`;

  performance.mark(endMark);

  try {
    performance.measure(measureName, startMark, endMark);
    const entries = performance.getEntriesByName(measureName);
    const entry = entries[entries.length - 1];

    if (entry) {
      const payload: FlowMeasurement = {
        flow: flowName,
        duration: entry.duration,
        url: location.href,
        sessionId: getSessionId(),
        timestamp: Date.now(),
      };
      sendMetric(payload);
    }
  } finally {
    performance.clearMarks(startMark);
    performance.clearMarks(endMark);
    performance.clearMeasures(measureName);
  }
}

// Usage: wrap any async flow
async function loadOrderHistory(): Promise<Order[]> {
  markFlowStart("order-history-load");
  try {
    const orders = await fetchOrders();
    await renderOrderList(orders);
    return orders;
  } finally {
    markFlowEnd("order-history-load");
  }
}

These measurements give you a product-level SLO. Instead of “LCP P75 under 2.5s,” you can define “order history load P95 under 1.8s” and alert when deploys regress it.

Build vs. Buy: Tradeoffs Table

ApproachSetup TimeCost at ScaleData OwnershipCustomizationPII ControlVendor Lock-in
Datadog RUMHoursHigh ($0.10-$0.15/session/month at volume)Vendor holds dataLimited to platform featuresScrubbing rules in Datadog UIHigh
Sentry (Browser SDK)HoursMedium (session replay drives cost)Vendor holds dataGood; SDK is extensibleScrubbing rules + beforeSend hookMedium
Self-hosted OTel CollectorDays to weeksLow (infra cost only)You own everythingFullFullNone
Sentry + OTel hybridDaysMediumSplit (errors in Sentry, traces self-hosted)GoodMixedLow to medium

The fully self-hosted path requires you to run an OpenTelemetry Collector, a trace backend (Jaeger or Tempo), and a metrics store for RUM data. The operational cost is real. Most teams at seed-to-Series A are better served by Sentry (errors) plus the OpenTelemetry JS SDK exporting to a collector proxy you control, which lets you scrub PII before data leaves your infrastructure.

Production Considerations

Sampling. Sending every span and every vital from every user does not scale. Define a sampling strategy before you ship. A 10% head-based sample of traces is usually enough to characterize performance distributions. For errors, send 100% (errors are rare enough that sampling them loses signal). For Core Web Vitals, 100% is fine because the payloads are tiny.

import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";

// 10% of traces, but always sample if a parent trace says to
const sampler = new ParentBasedSampler({
  root: new TraceIdRatioBasedSampler(0.1),
});

PII scrubbing. Browser errors and spans will capture URL parameters, form input values in breadcrumbs, and user identifiers in request bodies. Scrub before transmission, not after storage. At minimum: strip query parameters containing token, password, email, ssn, card; replace user IDs in URLs with a placeholder; sanitize fetch request body breadcrumbs to method and URL only.

function scrubUrl(url: string): string {
  try {
    const parsed = new URL(url);
    const sensitiveParams = ["token", "password", "email", "key", "secret", "auth"];
    sensitiveParams.forEach((param) => {
      if (parsed.searchParams.has(param)) {
        parsed.searchParams.set(param, "[REDACTED]");
      }
    });
    return parsed.toString();
  } catch {
    return url;
  }
}

Bundle size impact. The OpenTelemetry JS SDK is not small. @opentelemetry/sdk-trace-web plus the fetch instrumentation adds roughly 40-60 KB gzipped to your bundle. Lazy-load the instrumentation after the first user interaction if you are in a performance-sensitive context:

// In your app entry point
if (typeof window !== "undefined") {
  requestIdleCallback(() => {
    import("./observability/init").then(({ initObservability }) => {
      initObservability();
    });
  });
}

This defers the observability setup until the browser is idle, so it does not compete with your critical rendering path.

Proxy your collector endpoint. Do not point the OTLP exporter directly at an external collector. Browser CORS restrictions will cause issues, and you want a layer where you can scrub PII, apply sampling overrides, and rate-limit malicious traffic before it hits your backend. A lightweight Edge Function or worker proxy that validates the payload shape and strips sensitive attributes is worth the extra hop.

Alerting on frontend signals. Backend alerting is well-understood. Frontend alerting is usually an afterthought. Set up alerts on: LCP P75 degrading more than 20% from 7-day baseline after a deploy; error rate spiking more than 2 standard deviations from hourly mean; checkout flow P95 duration exceeding your SLO threshold. Tie these to your deploy pipeline so regressions are caught within minutes of a release.

Closing Insight

Frontend observability is not a nice-to-have once you have a production user base. It is the only way to know whether a deploy actually improved the experience for real users on real devices, or whether you shipped a regression that your staging environment never caught because staging runs on a fast network with no browser extensions and no accumulated localStorage state.

The OpenTelemetry JS SDK gives you a path to full-stack traces that cross the browser-to-server boundary without vendor lock-in. Start with error capture and Core Web Vitals collection (cheap to ship, immediately useful), then layer in distributed tracing for your most critical user flows. You do not need to instrument everything on day one. You need enough signal to know when something breaks and enough context to know why.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.