Frontend Architecture
Part 0 of 11Frontend Observability: Logs, Traces and Metrics Are Not Just a Backend Concern
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?