Back to Blog

Frontend Observability: Logs, Traces and Metrics Are Not Just a Backend Concern

Your backend has structured logging, distributed tracing, and metrics dashboards. Your frontend has console.log('here') and Sentry catching unhandled exceptions.

This disparity is a problem. When users report "the app is slow" or "something broke," your backend team can trace requests through services, correlate logs, and identify bottlenecks. Your frontend team opens DevTools and hopes to reproduce it.

Frontend observability is the missing half of your monitoring story.


The Three Pillars in Frontend Context

┌─────────────────────────────────────────────────────────────────────┐
│              THE THREE PILLARS OF OBSERVABILITY                     │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  LOGS                    TRACES                   METRICS           │
│  ────                    ──────                   ───────           │
│                                                                     │
│  What happened           Request journey          Aggregated data   │
│  at a point in time      across systems           over time         │
│                                                                     │
│  ┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐ │
│  │ User clicked    │    │ Browser         │    │ P95 API latency │ │
│  │ checkout button │    │    ↓            │    │ Error rate      │ │
│  │ at 14:32:01     │    │ API Gateway     │    │ Interaction     │ │
│  │                 │    │    ↓            │    │ to Next Paint   │ │
│  │ Cart validation │    │ Auth Service    │    │                 │ │
│  │ failed: empty   │    │    ↓            │    │ JS heap size    │ │
│  │ items array     │    │ Payment API     │    │ over time       │ │
│  └─────────────────┘    └─────────────────┘    └─────────────────┘ │
│                                                                     │
│  FRONTEND SPECIFICS:                                                │
│  ───────────────────                                                │
│                                                                     │
│  Logs:                                                              │
│  • User actions (clicks, navigation)                                │
│  • State transitions                                                │
│  • Component lifecycle events                                       │
│  • Error context (what was user doing?)                            │
│                                                                     │
│  Traces:                                                            │
│  • Browser → API correlation                                        │
│  • Component render waterfalls                                      │
│  • Resource loading sequences                                       │
│  • User journey spans                                               │
│                                                                     │
│  Metrics:                                                           │
│  • Core Web Vitals (LCP, FID, CLS)                                 │
│  • Custom performance marks                                         │
│  • Error rates by component/route                                   │
│  • Feature usage patterns                                           │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Structured Frontend Logging

Stop doing this:

// ❌ Unstructured logging
console.log('user clicked button');
console.log('API call failed', error);
console.log('rendering component');

These logs are unsearchable, uncorrelatable, and useless in production.

The Structured Logger

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface LogContext {
  // Identifiers
  sessionId: string;
  userId?: string;
  traceId?: string;
  spanId?: string;

  // Environment
  environment: string;
  version: string;
  userAgent: string;

  // Location
  route: string;
  component?: string;
}

interface LogEntry {
  timestamp: string;
  level: LogLevel;
  message: string;
  context: LogContext;
  data?: Record<string, any>;
  error?: {
    name: string;
    message: string;
    stack?: string;
  };
}

class FrontendLogger {
  private buffer: LogEntry[] = [];
  private flushInterval: number;
  private context: Partial<LogContext>;
  private readonly endpoint: string;
  private readonly bufferSize: number;
  private readonly sampleRate: number;

  constructor(config: {
    endpoint: string;
    bufferSize?: number;
    flushIntervalMs?: number;
    sampleRate?: number;
    context?: Partial<LogContext>;
  }) {
    this.endpoint = config.endpoint;
    this.bufferSize = config.bufferSize ?? 50;
    this.sampleRate = config.sampleRate ?? 1;
    this.context = config.context ?? {};

    // Flush periodically
    this.flushInterval = window.setInterval(
      () => this.flush(),
      config.flushIntervalMs ?? 10000
    );

    // Flush on page unload
    window.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flush();
      }
    });

    // Flush before unload
    window.addEventListener('pagehide', () => this.flush());
  }

  setContext(context: Partial<LogContext>) {
    this.context = { ...this.context, ...context };
  }

  private shouldSample(): boolean {
    return Math.random() < this.sampleRate;
  }

  private createEntry(
    level: LogLevel,
    message: string,
    data?: Record<string, any>,
    error?: Error
  ): LogEntry {
    return {
      timestamp: new Date().toISOString(),
      level,
      message,
      context: {
        sessionId: this.getSessionId(),
        userId: this.context.userId,
        traceId: this.context.traceId,
        spanId: this.context.spanId,
        environment: process.env.NODE_ENV ?? 'development',
        version: process.env.APP_VERSION ?? 'unknown',
        userAgent: navigator.userAgent,
        route: window.location.pathname,
        component: this.context.component,
        ...this.context
      },
      data,
      error: error ? {
        name: error.name,
        message: error.message,
        stack: error.stack
      } : undefined
    };
  }

  private getSessionId(): string {
    let sessionId = sessionStorage.getItem('observability_session_id');
    if (!sessionId) {
      sessionId = crypto.randomUUID();
      sessionStorage.setItem('observability_session_id', sessionId);
    }
    return sessionId;
  }

  debug(message: string, data?: Record<string, any>) {
    if (process.env.NODE_ENV === 'development') {
      console.debug(message, data);
    }
    // Don't send debug logs to server in production
  }

  info(message: string, data?: Record<string, any>) {
    if (!this.shouldSample()) return;

    const entry = this.createEntry('info', message, data);
    this.buffer.push(entry);

    if (process.env.NODE_ENV === 'development') {
      console.info(message, data);
    }

    if (this.buffer.length >= this.bufferSize) {
      this.flush();
    }
  }

  warn(message: string, data?: Record<string, any>) {
    const entry = this.createEntry('warn', message, data);
    this.buffer.push(entry);

    if (process.env.NODE_ENV === 'development') {
      console.warn(message, data);
    }

    if (this.buffer.length >= this.bufferSize) {
      this.flush();
    }
  }

  error(message: string, error?: Error, data?: Record<string, any>) {
    // Always log errors (no sampling)
    const entry = this.createEntry('error', message, data, error);
    this.buffer.push(entry);

    if (process.env.NODE_ENV === 'development') {
      console.error(message, error, data);
    }

    // Flush immediately for errors
    this.flush();
  }

  private async flush() {
    if (this.buffer.length === 0) return;

    const entries = [...this.buffer];
    this.buffer = [];

    try {
      // Use sendBeacon for reliability during page unload
      const useBeacon = document.visibilityState === 'hidden';

      if (useBeacon && navigator.sendBeacon) {
        navigator.sendBeacon(
          this.endpoint,
          JSON.stringify({ logs: entries })
        );
      } else {
        await fetch(this.endpoint, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ logs: entries }),
          keepalive: true
        });
      }
    } catch (error) {
      // Re-add to buffer on failure (but limit to prevent memory issues)
      if (this.buffer.length < this.bufferSize * 2) {
        this.buffer.unshift(...entries);
      }
      console.error('Failed to flush logs', error);
    }
  }

  destroy() {
    clearInterval(this.flushInterval);
    this.flush();
  }
}

// Singleton instance
export const logger = new FrontendLogger({
  endpoint: '/api/logs',
  bufferSize: 50,
  flushIntervalMs: 10000,
  sampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1
});

Logging Best Practices

// ✅ Structured, contextual logging

// User actions
logger.info('user_action', {
  action: 'button_click',
  target: 'checkout_submit',
  cartValue: cart.total,
  itemCount: cart.items.length
});

// State transitions
logger.info('state_transition', {
  from: 'cart',
  to: 'checkout',
  trigger: 'user_navigation'
});

// API calls (with trace context)
logger.info('api_request_start', {
  method: 'POST',
  url: '/api/orders',
  traceId: currentTrace.id
});

// Errors with context
logger.error('checkout_failed', checkoutError, {
  step: 'payment_processing',
  paymentMethod: selectedMethod,
  cartId: cart.id,
  // Don't log sensitive data!
  // cardNumber: '...' ❌
});

Log Levels Strategy

┌─────────────────────────────────────────────────────────────────────┐
│                    LOG LEVEL USAGE                                  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  DEBUG     Development only. Verbose component state,               │
│            render cycles, hook executions.                          │
│            Never sent to server.                                    │
│                                                                     │
│  INFO      User actions, navigation, feature usage.                 │
│            Sampled in production (10-25%).                          │
│            Used for understanding user behavior.                    │
│                                                                     │
│  WARN      Recoverable issues. Retry succeeded,                     │
│            fallback used, deprecated feature accessed.              │
│            Not sampled, but not immediately flushed.                │
│                                                                     │
│  ERROR     Unrecoverable failures. API errors,                      │
│            exceptions, failed user operations.                      │
│            Never sampled, immediately flushed.                      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Distributed Tracing: Browser to Backend

The holy grail: a single trace ID that follows a user action from click to database and back.

Trace Propagation Architecture

┌─────────────────────────────────────────────────────────────────────┐
│              DISTRIBUTED TRACE: CHECKOUT FLOW                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Trace ID: abc-123-def-456                                          │
│                                                                     │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │ BROWSER (Span: checkout-flow)                        800ms │   │
│  │                                                             │   │
│  │  ┌─────────────────────────────────────────────────────┐   │   │
│  │  │ Span: validate-cart                           50ms │   │   │
│  │  └─────────────────────────────────────────────────────┘   │   │
│  │                                                             │   │
│  │  ┌─────────────────────────────────────────────────────┐   │   │
│  │  │ Span: api-call POST /api/orders               600ms │   │   │
│  │  │                                                     │   │   │
│  │  │  ┌─────────────────────────────────────────────┐   │   │   │
│  │  │  │ API GATEWAY                            20ms │   │   │   │
│  │  │  └─────────────────────────────────────────────┘   │   │   │
│  │  │  ┌─────────────────────────────────────────────┐   │   │   │
│  │  │  │ ORDER SERVICE                         400ms │   │   │   │
│  │  │  │  ┌─────────────────────────────────────┐   │   │   │   │
│  │  │  │  │ INVENTORY CHECK                80ms │   │   │   │   │
│  │  │  │  └─────────────────────────────────────┘   │   │   │   │
│  │  │  │  ┌─────────────────────────────────────┐   │   │   │   │
│  │  │  │  │ PAYMENT SERVICE               250ms │   │   │   │   │
│  │  │  │  └─────────────────────────────────────┘   │   │   │   │
│  │  │  └─────────────────────────────────────────────┘   │   │   │
│  │  │  ┌─────────────────────────────────────────────┐   │   │   │
│  │  │  │ RESPONSE SERIALIZATION              30ms │   │   │   │
│  │  │  └─────────────────────────────────────────────┘   │   │   │
│  │  └─────────────────────────────────────────────────────┘   │   │
│  │                                                             │   │
│  │  ┌─────────────────────────────────────────────────────┐   │   │
│  │  │ Span: update-ui                              150ms │   │   │
│  │  └─────────────────────────────────────────────────────┘   │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Frontend Tracing Implementation

interface Span {
  traceId: string;
  spanId: string;
  parentSpanId?: string;
  name: string;
  startTime: number;
  endTime?: number;
  duration?: number;
  status: 'ok' | 'error';
  attributes: Record<string, string | number | boolean>;
  events: Array<{
    name: string;
    timestamp: number;
    attributes?: Record<string, any>;
  }>;
}

class FrontendTracer {
  private activeSpans: Map<string, Span> = new Map();
  private completedSpans: Span[] = [];
  private currentTraceId: string | null = null;
  private spanStack: string[] = [];
  private readonly endpoint: string;

  constructor(endpoint: string) {
    this.endpoint = endpoint;

    // Flush completed spans periodically
    setInterval(() => this.flush(), 5000);

    // Flush on page hide
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flush();
      }
    });
  }

  // Start a new trace (top-level user action)
  startTrace(name: string, attributes: Record<string, any> = {}): Span {
    this.currentTraceId = this.generateId();
    return this.startSpan(name, attributes);
  }

  // Start a span within current trace
  startSpan(name: string, attributes: Record<string, any> = {}): Span {
    const spanId = this.generateId();
    const parentSpanId = this.spanStack[this.spanStack.length - 1];

    const span: Span = {
      traceId: this.currentTraceId!,
      spanId,
      parentSpanId,
      name,
      startTime: performance.now(),
      status: 'ok',
      attributes: {
        ...attributes,
        'browser.url': window.location.href,
        'browser.userAgent': navigator.userAgent
      },
      events: []
    };

    this.activeSpans.set(spanId, span);
    this.spanStack.push(spanId);

    return span;
  }

  // Add event to current span
  addEvent(name: string, attributes?: Record<string, any>) {
    const currentSpanId = this.spanStack[this.spanStack.length - 1];
    const span = this.activeSpans.get(currentSpanId);

    if (span) {
      span.events.push({
        name,
        timestamp: performance.now(),
        attributes
      });
    }
  }

  // Set span attributes
  setAttributes(attributes: Record<string, any>) {
    const currentSpanId = this.spanStack[this.spanStack.length - 1];
    const span = this.activeSpans.get(currentSpanId);

    if (span) {
      Object.assign(span.attributes, attributes);
    }
  }

  // End current span
  endSpan(status: 'ok' | 'error' = 'ok') {
    const spanId = this.spanStack.pop();
    if (!spanId) return;

    const span = this.activeSpans.get(spanId);
    if (!span) return;

    span.endTime = performance.now();
    span.duration = span.endTime - span.startTime;
    span.status = status;

    this.activeSpans.delete(spanId);
    this.completedSpans.push(span);

    // If this was the root span, clear trace context
    if (this.spanStack.length === 0) {
      this.currentTraceId = null;
    }
  }

  // Get headers for propagating trace to backend
  getTraceHeaders(): Record<string, string> {
    const currentSpanId = this.spanStack[this.spanStack.length - 1];

    if (!this.currentTraceId || !currentSpanId) {
      return {};
    }

    // W3C Trace Context format
    return {
      'traceparent': `00-${this.currentTraceId}-${currentSpanId}-01`,
      'tracestate': ''
    };
  }

  // Get current trace context for logging correlation
  getContext(): { traceId?: string; spanId?: string } {
    return {
      traceId: this.currentTraceId ?? undefined,
      spanId: this.spanStack[this.spanStack.length - 1]
    };
  }

  private generateId(): string {
    return Array.from(crypto.getRandomValues(new Uint8Array(16)))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
  }

  private async flush() {
    if (this.completedSpans.length === 0) return;

    const spans = [...this.completedSpans];
    this.completedSpans = [];

    try {
      if (document.visibilityState === 'hidden' && navigator.sendBeacon) {
        navigator.sendBeacon(this.endpoint, JSON.stringify({ spans }));
      } else {
        await fetch(this.endpoint, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ spans }),
          keepalive: true
        });
      }
    } catch (error) {
      // Re-add spans on failure
      this.completedSpans.unshift(...spans);
    }
  }
}

export const tracer = new FrontendTracer('/api/traces');

Traced Fetch Wrapper

async function tracedFetch(
  input: RequestInfo | URL,
  init?: RequestInit
): Promise<Response> {
  const url = typeof input === 'string' ? input : input.toString();
  const method = init?.method ?? 'GET';

  const span = tracer.startSpan('http_request', {
    'http.method': method,
    'http.url': url
  });

  try {
    // Inject trace headers
    const headers = new Headers(init?.headers);
    const traceHeaders = tracer.getTraceHeaders();
    Object.entries(traceHeaders).forEach(([key, value]) => {
      headers.set(key, value);
    });

    tracer.addEvent('request_start');

    const response = await fetch(input, {
      ...init,
      headers
    });

    tracer.addEvent('response_received');
    tracer.setAttributes({
      'http.status_code': response.status,
      'http.response_content_length': response.headers.get('content-length')
    });

    tracer.endSpan(response.ok ? 'ok' : 'error');
    return response;

  } catch (error) {
    tracer.setAttributes({
      'error.type': error instanceof Error ? error.name : 'unknown',
      'error.message': error instanceof Error ? error.message : String(error)
    });
    tracer.endSpan('error');
    throw error;
  }
}

// Usage
async function checkout(cart: Cart) {
  tracer.startTrace('checkout_flow', { cartId: cart.id });

  try {
    tracer.startSpan('validate_cart');
    validateCart(cart);
    tracer.endSpan();

    tracer.startSpan('create_order');
    const response = await tracedFetch('/api/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(cart)
    });
    const order = await response.json();
    tracer.endSpan();

    tracer.startSpan('update_ui');
    await router.push(`/orders/${order.id}`);
    tracer.endSpan();

    tracer.endSpan('ok');  // End trace
    return order;

  } catch (error) {
    tracer.endSpan('error');
    throw error;
  }
}

React Integration with Custom Hook

function useTraced<T>(
  name: string,
  fn: () => Promise<T>,
  deps: any[]
): {
  data: T | null;
  loading: boolean;
  error: Error | null;
  traceId: string | null;
} {
  const [state, setState] = useState<{
    data: T | null;
    loading: boolean;
    error: Error | null;
    traceId: string | null;
  }>({
    data: null,
    loading: true,
    error: null,
    traceId: null
  });

  useEffect(() => {
    let cancelled = false;

    const execute = async () => {
      const span = tracer.startTrace(name);
      setState(s => ({ ...s, loading: true, traceId: span.traceId }));

      try {
        const data = await fn();
        if (!cancelled) {
          tracer.endSpan('ok');
          setState({ data, loading: false, error: null, traceId: span.traceId });
        }
      } catch (error) {
        if (!cancelled) {
          tracer.endSpan('error');
          setState({
            data: null,
            loading: false,
            error: error as Error,
            traceId: span.traceId
          });
        }
      }
    };

    execute();

    return () => {
      cancelled = true;
    };
  }, deps);

  return state;
}

// Usage
function OrderDetails({ orderId }: { orderId: string }) {
  const { data, loading, error, traceId } = useTraced(
    'load_order_details',
    () => tracedFetch(`/api/orders/${orderId}`).then(r => r.json()),
    [orderId]
  );

  if (error) {
    // Include traceId in error report
    return <ErrorDisplay error={error} traceId={traceId} />;
  }

  // ...
}

Error Boundaries as Observability Checkpoints

Error boundaries aren't just for displaying fallback UI—they're observation points.

interface ErrorBoundaryProps {
  children: React.ReactNode;
  fallback: React.ReactNode | ((error: Error, reset: () => void) => React.ReactNode);
  name: string;  // For identification in logs
  onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

class ObservableErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  private errorTimestamp: number | null = null;

  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    this.errorTimestamp = Date.now();

    // Structured error logging
    logger.error('react_error_boundary', error, {
      boundary: this.props.name,
      componentStack: errorInfo.componentStack,
      traceId: tracer.getContext().traceId,
      route: window.location.pathname
    });

    // Track error metric
    metrics.increment('error_boundary.caught', {
      boundary: this.props.name,
      errorType: error.name
    });

    // End any active spans with error status
    tracer.setAttributes({
      'error.boundary': this.props.name,
      'error.type': error.name,
      'error.message': error.message
    });
    tracer.endSpan('error');

    // Custom error handler
    this.props.onError?.(error, errorInfo);
  }

  reset = () => {
    // Track recovery attempt
    if (this.errorTimestamp) {
      const recoveryTime = Date.now() - this.errorTimestamp;
      metrics.timing('error_boundary.recovery_time', recoveryTime, {
        boundary: this.props.name
      });
    }

    logger.info('error_boundary_reset', {
      boundary: this.props.name,
      traceId: tracer.getContext().traceId
    });

    this.setState({ hasError: false, error: null });
    this.errorTimestamp = null;
  };

  render() {
    if (this.state.hasError) {
      const { fallback } = this.props;

      if (typeof fallback === 'function') {
        return fallback(this.state.error!, this.reset);
      }

      return fallback;
    }

    return this.props.children;
  }
}

// Higher-order component for easy wrapping
function withErrorBoundary<P extends object>(
  Component: React.ComponentType<P>,
  options: {
    name: string;
    fallback: React.ReactNode | ((error: Error, reset: () => void) => React.ReactNode);
  }
): React.FC<P> {
  return function WrappedComponent(props: P) {
    return (
      <ObservableErrorBoundary name={options.name} fallback={options.fallback}>
        <Component {...props} />
      </ObservableErrorBoundary>
    );
  };
}

Strategic Boundary Placement

┌─────────────────────────────────────────────────────────────────────┐
│                 ERROR BOUNDARY STRATEGY                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │ App Root Boundary                                           │   │
│  │ name="app_root"                                             │   │
│  │ Catches: Fatal errors, unhandled rejections                 │   │
│  │ Fallback: Full-page error with "refresh" button             │   │
│  │                                                             │   │
│  │  ┌─────────────────────────────────────────────────────┐   │   │
│  │  │ Layout Boundary                                     │   │   │
│  │  │ name="layout"                                       │   │   │
│  │  │ Catches: Navigation errors, layout issues           │   │   │
│  │  │ Fallback: Simplified layout with error message      │   │   │
│  │  │                                                     │   │   │
│  │  │  ┌────────────────────┐  ┌────────────────────┐    │   │   │
│  │  │  │ Sidebar Boundary   │  │ Main Content       │    │   │   │
│  │  │  │ name="sidebar"     │  │ Boundary           │    │   │   │
│  │  │  │                    │  │ name="main_content"│    │   │   │
│  │  │  │ Catches: Nav/menu  │  │                    │    │   │   │
│  │  │  │ errors             │  │ Catches: Page-     │    │   │   │
│  │  │  │                    │  │ level errors       │    │   │   │
│  │  │  │ Fallback: Minimal  │  │                    │    │   │   │
│  │  │  │ nav links          │  │ ┌──────────────┐  │    │   │   │
│  │  │  │                    │  │ │ Feature      │  │    │   │   │
│  │  │  │                    │  │ │ Boundary     │  │    │   │   │
│  │  │  │                    │  │ │ name="cart"  │  │    │   │   │
│  │  │  │                    │  │ │              │  │    │   │   │
│  │  │  │                    │  │ │ Catches:     │  │    │   │   │
│  │  │  │                    │  │ │ Cart-only    │  │    │   │   │
│  │  │  │                    │  │ │ errors       │  │    │   │   │
│  │  │  │                    │  │ │              │  │    │   │   │
│  │  │  │                    │  │ │ Fallback:    │  │    │   │   │
│  │  │  │                    │  │ │ "Cart error" │  │    │   │   │
│  │  │  │                    │  │ │ + retry      │  │    │   │   │
│  │  │  │                    │  │ └──────────────┘  │    │   │   │
│  │  │  └────────────────────┘  └────────────────────┘    │   │   │
│  │  └─────────────────────────────────────────────────────┘   │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
│  BENEFITS:                                                          │
│  • Errors are isolated - cart error doesn't break navigation       │
│  • Clear observability - know exactly which section failed         │
│  • Granular recovery - can reset individual components             │
│  • Better UX - most of the page stays functional                   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Frontend Metrics

Core Web Vitals + Custom Metrics

interface MetricEntry {
  name: string;
  value: number;
  timestamp: number;
  tags: Record<string, string>;
}

class FrontendMetrics {
  private buffer: MetricEntry[] = [];
  private readonly endpoint: string;

  constructor(endpoint: string) {
    this.endpoint = endpoint;
    this.setupWebVitals();
    this.setupCustomMetrics();

    // Flush periodically
    setInterval(() => this.flush(), 30000);

    // Flush on page hide
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flush();
      }
    });
  }

  private setupWebVitals() {
    // Largest Contentful Paint
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      this.gauge('web_vitals.lcp', lastEntry.startTime, {
        route: window.location.pathname
      });
    }).observe({ type: 'largest-contentful-paint', buffered: true });

    // First Input Delay
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      entries.forEach((entry: any) => {
        this.gauge('web_vitals.fid', entry.processingStart - entry.startTime, {
          route: window.location.pathname
        });
      });
    }).observe({ type: 'first-input', buffered: true });

    // Cumulative Layout Shift
    let clsValue = 0;
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries() as any[]) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
        }
      }
      this.gauge('web_vitals.cls', clsValue, {
        route: window.location.pathname
      });
    }).observe({ type: 'layout-shift', buffered: true });

    // Interaction to Next Paint (INP)
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      entries.forEach((entry: any) => {
        this.histogram('web_vitals.inp', entry.duration, {
          route: window.location.pathname,
          interactionType: entry.name
        });
      });
    }).observe({ type: 'event', buffered: true, durationThreshold: 16 });
  }

  private setupCustomMetrics() {
    // Long tasks
    new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        this.histogram('browser.long_task', entry.duration, {
          route: window.location.pathname
        });

        if (entry.duration > 100) {
          logger.warn('long_task_detected', {
            duration: entry.duration,
            startTime: entry.startTime
          });
        }
      });
    }).observe({ type: 'longtask', buffered: true });

    // Resource timing
    new PerformanceObserver((list) => {
      list.getEntries().forEach((entry: PerformanceResourceTiming) => {
        const resourceType = this.getResourceType(entry.name);
        this.histogram('resource.load_time', entry.duration, {
          type: resourceType,
          cached: entry.transferSize === 0 ? 'true' : 'false'
        });
      });
    }).observe({ type: 'resource', buffered: true });

    // Memory (Chrome only)
    if ((performance as any).memory) {
      setInterval(() => {
        const memory = (performance as any).memory;
        this.gauge('browser.memory.used_heap', memory.usedJSHeapSize);
        this.gauge('browser.memory.total_heap', memory.totalJSHeapSize);
        this.gauge('browser.memory.heap_limit', memory.jsHeapSizeLimit);
      }, 30000);
    }
  }

  private getResourceType(url: string): string {
    if (url.endsWith('.js')) return 'script';
    if (url.endsWith('.css')) return 'stylesheet';
    if (url.match(/\.(png|jpg|jpeg|gif|webp|svg)$/)) return 'image';
    if (url.match(/\.(woff|woff2|ttf|otf)$/)) return 'font';
    if (url.includes('/api/')) return 'api';
    return 'other';
  }

  // Counter - for things you count (errors, clicks)
  increment(name: string, tags: Record<string, string> = {}, value = 1) {
    this.buffer.push({
      name: `counter.${name}`,
      value,
      timestamp: Date.now(),
      tags: { ...tags, environment: process.env.NODE_ENV ?? 'development' }
    });
  }

  // Gauge - for current values (memory, queue length)
  gauge(name: string, value: number, tags: Record<string, string> = {}) {
    this.buffer.push({
      name: `gauge.${name}`,
      value,
      timestamp: Date.now(),
      tags: { ...tags, environment: process.env.NODE_ENV ?? 'development' }
    });
  }

  // Histogram - for distributions (latencies, sizes)
  histogram(name: string, value: number, tags: Record<string, string> = {}) {
    this.buffer.push({
      name: `histogram.${name}`,
      value,
      timestamp: Date.now(),
      tags: { ...tags, environment: process.env.NODE_ENV ?? 'development' }
    });
  }

  // Timing - convenience wrapper for histograms
  timing(name: string, durationMs: number, tags: Record<string, string> = {}) {
    this.histogram(name, durationMs, tags);
  }

  // Start a timer, returns function to stop and record
  startTimer(name: string, tags: Record<string, string> = {}): () => void {
    const start = performance.now();
    return () => {
      const duration = performance.now() - start;
      this.timing(name, duration, tags);
    };
  }

  private async flush() {
    if (this.buffer.length === 0) return;

    const metrics = [...this.buffer];
    this.buffer = [];

    try {
      if (document.visibilityState === 'hidden' && navigator.sendBeacon) {
        navigator.sendBeacon(this.endpoint, JSON.stringify({ metrics }));
      } else {
        await fetch(this.endpoint, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ metrics }),
          keepalive: true
        });
      }
    } catch (error) {
      this.buffer.unshift(...metrics);
    }
  }
}

export const metrics = new FrontendMetrics('/api/metrics');

Using Metrics Effectively

// Track user interactions
function useTrackInteraction(name: string) {
  return useCallback((metadata?: Record<string, string>) => {
    metrics.increment('interaction', {
      name,
      route: window.location.pathname,
      ...metadata
    });
  }, [name]);
}

function AddToCartButton({ productId }: { productId: string }) {
  const trackInteraction = useTrackInteraction('add_to_cart');

  const handleClick = async () => {
    const stopTimer = metrics.startTimer('add_to_cart.duration');

    try {
      await addToCart(productId);
      trackInteraction({ productId, success: 'true' });
    } catch (error) {
      trackInteraction({ productId, success: 'false' });
      metrics.increment('add_to_cart.error', { reason: error.message });
    } finally {
      stopTimer();
    }
  };

  return <button onClick={handleClick}>Add to Cart</button>;
}

// Track component render performance
function useRenderMetrics(componentName: string) {
  const renderCount = useRef(0);
  const mountTime = useRef(performance.now());

  useEffect(() => {
    const duration = performance.now() - mountTime.current;
    metrics.timing('component.mount', duration, { component: componentName });

    return () => {
      metrics.gauge('component.render_count', renderCount.current, {
        component: componentName
      });
    };
  }, [componentName]);

  useEffect(() => {
    renderCount.current++;
  });
}

// Track API performance
function useAPIMetrics() {
  return useMemo(() => ({
    trackRequest: (endpoint: string, method: string, duration: number, status: number) => {
      metrics.histogram('api.request.duration', duration, {
        endpoint,
        method,
        status: String(status),
        status_class: `${Math.floor(status / 100)}xx`
      });

      if (status >= 400) {
        metrics.increment('api.request.error', {
          endpoint,
          status: String(status)
        });
      }
    }
  }), []);
}

The Complete Stack

┌─────────────────────────────────────────────────────────────────────────────┐
│                  MATURE FRONTEND OBSERVABILITY STACK                         │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  COLLECTION LAYER (Browser)                                                  │
│  ────────────────────────────                                                │
│                                                                             │
│  ┌───────────────┐  ┌───────────────┐  ┌───────────────┐                   │
│  │    Logger     │  │    Tracer     │  │   Metrics     │                   │
│  │               │  │               │  │               │                   │
│  │ • Structured  │  │ • Spans       │  │ • Web Vitals  │                   │
│  │   logs        │  │ • Trace       │  │ • Custom      │                   │
│  │ • Context     │  │   propagation │  │   counters    │                   │
│  │ • Sampling    │  │ • Headers     │  │ • Histograms  │                   │
│  └───────┬───────┘  └───────┬───────┘  └───────┬───────┘                   │
│          │                  │                  │                           │
│          └──────────────────┼──────────────────┘                           │
│                             │                                               │
│                             ▼                                               │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                    Buffering + Batching                             │   │
│  │                                                                     │   │
│  │  • Buffer events in memory                                         │   │
│  │  • Batch send every N seconds or N events                          │   │
│  │  • Use sendBeacon on page hide                                     │   │
│  │  • Retry failed sends                                              │   │
│  └───────────────────────────────┬─────────────────────────────────────┘   │
│                                  │                                         │
│  ════════════════════════════════╪═════════════════════════════════════   │
│                                  │  Network                                │
│  ════════════════════════════════╪═════════════════════════════════════   │
│                                  │                                         │
│  INGESTION LAYER (Backend)       ▼                                         │
│  ─────────────────────────────────                                         │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                    /api/telemetry                                   │   │
│  │                                                                     │   │
│  │  • Validate payloads                                               │   │
│  │  • Enrich with server-side context (IP geolocation, etc.)         │   │
│  │  • Route to appropriate backend                                    │   │
│  └───────────────────────────────┬─────────────────────────────────────┘   │
│                                  │                                         │
│         ┌────────────────────────┼────────────────────────┐               │
│         │                        │                        │               │
│         ▼                        ▼                        ▼               │
│  ┌─────────────┐          ┌─────────────┐          ┌─────────────┐       │
│  │ Log Storage │          │   Tracing   │          │   Metrics   │       │
│  │             │          │   Backend   │          │   Backend   │       │
│  │ Elasticsearch│          │             │          │             │       │
│  │ Loki        │          │ Jaeger      │          │ Prometheus  │       │
│  │ CloudWatch  │          │ Tempo       │          │ Datadog     │       │
│  │             │          │ Honeycomb   │          │ Grafana     │       │
│  └──────┬──────┘          └──────┬──────┘          └──────┬──────┘       │
│         │                        │                        │               │
│         └────────────────────────┼────────────────────────┘               │
│                                  │                                         │
│                                  ▼                                         │
│  VISUALIZATION LAYER                                                        │
│  ────────────────────                                                        │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                        Dashboards                                   │   │
│  │                                                                     │   │
│  │  ┌───────────────┐  ┌───────────────┐  ┌───────────────┐           │   │
│  │  │ Error Rate    │  │  P95 Latency  │  │  Web Vitals   │           │   │
│  │  │ by Route      │  │  by Endpoint  │  │  over Time    │           │   │
│  │  └───────────────┘  └───────────────┘  └───────────────┘           │   │
│  │                                                                     │   │
│  │  ┌───────────────┐  ┌───────────────┐  ┌───────────────┐           │   │
│  │  │ Active Users  │  │  Feature      │  │  JS Errors    │           │   │
│  │  │ Real-time     │  │  Usage        │  │  Top 10       │           │   │
│  │  └───────────────┘  └───────────────┘  └───────────────┘           │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  ALERTING                                                                   │
│  ────────                                                                   │
│                                                                             │
│  • Error rate > 1% for 5 minutes                                           │
│  • P95 latency > 3s for 10 minutes                                         │
│  • LCP > 2.5s for > 25% of users                                           │
│  • New error type first seen                                               │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Unified Telemetry Client

// Putting it all together
interface TelemetryConfig {
  endpoint: string;
  serviceName: string;
  version: string;
  environment: string;
  sampleRate?: number;
}

class FrontendTelemetry {
  readonly logger: FrontendLogger;
  readonly tracer: FrontendTracer;
  readonly metrics: FrontendMetrics;

  constructor(config: TelemetryConfig) {
    this.logger = new FrontendLogger({
      endpoint: `${config.endpoint}/logs`,
      sampleRate: config.sampleRate,
      context: {
        environment: config.environment,
        version: config.version
      }
    });

    this.tracer = new FrontendTracer(`${config.endpoint}/traces`);

    this.metrics = new FrontendMetrics(`${config.endpoint}/metrics`);

    // Connect them - logs include trace context
    const originalLoggerInfo = this.logger.info.bind(this.logger);
    this.logger.info = (message, data) => {
      originalLoggerInfo(message, {
        ...data,
        ...this.tracer.getContext()
      });
    };

    // Setup global error handlers
    this.setupGlobalHandlers();
  }

  private setupGlobalHandlers() {
    // Unhandled errors
    window.addEventListener('error', (event) => {
      this.logger.error('unhandled_error', event.error, {
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno
      });
      this.metrics.increment('error.unhandled', {
        type: event.error?.name || 'unknown'
      });
    });

    // Unhandled promise rejections
    window.addEventListener('unhandledrejection', (event) => {
      this.logger.error('unhandled_rejection', event.reason, {
        type: 'promise_rejection'
      });
      this.metrics.increment('error.unhandled_rejection');
    });

    // Console error override (catch third-party errors)
    const originalConsoleError = console.error;
    console.error = (...args) => {
      this.metrics.increment('console.error');
      originalConsoleError.apply(console, args);
    };
  }

  // Convenience method for traced operations
  async traced<T>(
    name: string,
    fn: () => Promise<T>,
    attributes?: Record<string, any>
  ): Promise<T> {
    const span = this.tracer.startSpan(name, attributes);
    const stopTimer = this.metrics.startTimer(`operation.${name}`);

    try {
      const result = await fn();
      this.tracer.endSpan('ok');
      return result;
    } catch (error) {
      this.tracer.setAttributes({
        'error.type': error instanceof Error ? error.name : 'unknown',
        'error.message': error instanceof Error ? error.message : String(error)
      });
      this.tracer.endSpan('error');
      this.logger.error(`${name}_failed`, error as Error, attributes);
      throw error;
    } finally {
      stopTimer();
    }
  }

  // Create observable fetch
  fetch: typeof fetch = async (input, init) => {
    const url = typeof input === 'string' ? input : input.toString();
    const method = init?.method ?? 'GET';

    return this.traced(
      'fetch',
      async () => {
        const headers = new Headers(init?.headers);
        Object.entries(this.tracer.getTraceHeaders()).forEach(([k, v]) => {
          headers.set(k, v);
        });

        const response = await fetch(input, { ...init, headers });

        this.tracer.setAttributes({
          'http.status_code': response.status,
          'http.url': url,
          'http.method': method
        });

        return response;
      },
      { 'http.url': url, 'http.method': method }
    );
  };
}

// Initialize once
export const telemetry = new FrontendTelemetry({
  endpoint: process.env.NEXT_PUBLIC_TELEMETRY_ENDPOINT!,
  serviceName: 'web-app',
  version: process.env.NEXT_PUBLIC_VERSION!,
  environment: process.env.NODE_ENV,
  sampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1
});

// Export convenience accessors
export const { logger, tracer, metrics } = telemetry;

Dashboard Examples

Essential Frontend Dashboard

┌─────────────────────────────────────────────────────────────────────┐
│                    FRONTEND OBSERVABILITY DASHBOARD                  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  HEALTH OVERVIEW                               Last 24 hours        │
│  ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐       │
│  │  Error Rate     │ │  Avg LCP        │ │  Active Users   │       │
│  │     0.23%       │ │    1.8s         │ │    12,453       │       │
│  │    ▼ 0.05%      │ │   ▼ 200ms       │ │   ▲ 5.2%        │       │
│  └─────────────────┘ └─────────────────┘ └─────────────────┘       │
│                                                                     │
│  CORE WEB VITALS                                                    │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  LCP Distribution                                           │   │
│  │  ████████████████████░░░░░░░░  75% Good (<2.5s)             │   │
│  │  ██████░░░░░░░░░░░░░░░░░░░░░░  18% Needs Improvement        │   │
│  │  ██░░░░░░░░░░░░░░░░░░░░░░░░░░   7% Poor (>4s)               │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
│  ERROR RATE BY ROUTE                                                │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  /checkout     ████████░░░░  2.1% ⚠                        │   │
│  │  /product/:id  ███░░░░░░░░░  0.8%                          │   │
│  │  /cart         ██░░░░░░░░░░  0.5%                          │   │
│  │  /home         █░░░░░░░░░░░  0.2%                          │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
│  API LATENCY (P95)                                                  │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │    ^                                                        │   │
│  │ 2s │              ╭─╮                                       │   │
│  │    │    ╭────────╯  ╰────╮                                 │   │
│  │ 1s │───╯                  ╰───────────────                  │   │
│  │    │                                                        │   │
│  │    └────────────────────────────────────────────────▶       │   │
│  │        6am      12pm       6pm       12am                   │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
│  TOP ERRORS (Last hour)                                             │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  TypeError: Cannot read property 'id' of undefined    (127) │   │
│  │  NetworkError: Failed to fetch                         (84) │   │
│  │  ChunkLoadError: Loading chunk 23 failed               (31) │   │
│  │  RangeError: Invalid array length                      (12) │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Checklist: Frontend Observability Maturity

┌─────────────────────────────────────────────────────────────────────┐
│                    MATURITY CHECKLIST                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  LEVEL 1: BASIC                                                     │
│  □ Unhandled error capture (Sentry, etc.)                          │
│  □ Basic console logging                                            │
│  □ Manual performance timing                                        │
│                                                                     │
│  LEVEL 2: STRUCTURED                                                │
│  □ Structured logging with context                                  │
│  □ Session/user ID correlation                                      │
│  □ Core Web Vitals collection                                       │
│  □ Error boundaries in React                                        │
│  □ Basic dashboards                                                 │
│                                                                     │
│  LEVEL 3: CORRELATED                                                │
│  □ Distributed tracing (browser to backend)                        │
│  □ Trace ID in logs                                                 │
│  □ Custom metrics (counters, histograms)                           │
│  □ Feature usage tracking                                           │
│  □ Alerting on error rate and latency                              │
│                                                                     │
│  LEVEL 4: PROACTIVE                                                 │
│  □ Real User Monitoring (RUM)                                       │
│  □ Synthetic monitoring (Playwright, etc.)                         │
│  □ Performance budgets with CI enforcement                         │
│  □ Anomaly detection                                                │
│  □ User session replay (with privacy controls)                     │
│                                                                     │
│  LEVEL 5: ADVANCED                                                  │
│  □ Correlation with business metrics                               │
│  □ A/B test observability                                           │
│  □ Resource timing optimization                                     │
│  □ Memory leak detection                                            │
│  □ Automated incident response                                      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Common Pitfalls

┌─────────────────────────────────────────────────────────────────────┐
│                    PITFALLS TO AVOID                                │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. Logging PII                                                     │
│     ────────────                                                    │
│     ✗ logger.info('User logged in', { email, password })           │
│     ✓ logger.info('User logged in', { userId, method: 'email' })   │
│                                                                     │
│  2. No sampling in production                                       │
│     ─────────────────────────                                       │
│     ✗ Every user sends every log → massive costs                   │
│     ✓ Sample INFO/DEBUG, always send WARN/ERROR                    │
│                                                                     │
│  3. Synchronous logging                                             │
│     ──────────────────────                                          │
│     ✗ await fetch('/log') on every action → slow UI               │
│     ✓ Buffer and batch, async send                                 │
│                                                                     │
│  4. Missing page unload handling                                    │
│     ──────────────────────────                                      │
│     ✗ User leaves, buffered logs lost                              │
│     ✓ visibilitychange + sendBeacon                                │
│                                                                     │
│  5. No correlation between pillars                                  │
│     ─────────────────────────────                                   │
│     ✗ Separate logs, traces, metrics - can't connect them         │
│     ✓ traceId in all logs, metrics tagged with route              │
│                                                                     │
│  6. Alert fatigue                                                   │
│     ────────────                                                    │
│     ✗ Alert on every error → ignored                               │
│     ✓ Alert on rate changes, new error types                      │
│                                                                     │
│  7. Over-instrumentation                                            │
│     ────────────────────                                            │
│     ✗ Trace every function call                                    │
│     ✓ Trace user-facing operations and boundaries                  │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Closing Thoughts

Frontend observability is not about adding more console.log statements. It's about answering questions:

  • Why did this user see an error?
  • What was slow about their experience?
  • Did this deploy make things better or worse?
  • What features do users actually use?

The backend figured this out years ago. Every request gets a trace ID. Logs are structured. Metrics power dashboards and alerts.

The frontend deserves the same treatment. Your users' experience happens in the browser, not in your server logs. Build the observability stack that lets you see what they see.

Start with structured logging and trace propagation. Add Core Web Vitals. Layer in custom metrics. Connect it all with trace IDs.

Then when someone says "the app feels slow," you won't be opening DevTools and hoping to reproduce it. You'll pull up the trace and see exactly what happened.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.