正在加载,请稍候…

Frontend Observability: Real User Monitoring, Error Tracking, and Performance Budgets

Build a complete frontend observability stack with Core Web Vitals, Sentry error tracking, custom RUM, performance budgets in CI, and source map configuration.

Why Frontend Observability Matters

Backend observability is mature — distributed tracing, structured logging, metrics dashboards. Frontend observability is catching up. In 2026, production frontend issues cause measurable business impact: a 100ms increase in page load time reduces conversion by 1%. A JavaScript error blocking checkout loses revenue immediately.

This guide covers the full frontend observability stack: Real User Monitoring (RUM) for performance, error tracking with Sentry, custom instrumentation, and performance budgets enforced in CI.

Core Web Vitals: The Metrics That Matter

Google's Core Web Vitals are the canonical frontend performance metrics, tied directly to search ranking since 2021:

  • LCP (Largest Contentful Paint): How long until the main content is visible. Target: < 2.5s
  • FID/INP (Interaction to Next Paint): Responsiveness to user input. Target: < 200ms (INP replaces FID)
  • CLS (Cumulative Layout Shift): Visual stability during load. Target: < 0.1
// Collect Core Web Vitals with web-vitals library
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals'

function reportWebVital(metric: Metric) {
  // Send to your analytics endpoint
  fetch('/api/vitals', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
      delta: metric.delta,
      id: metric.id,
      navigationType: metric.navigationType,
      // Include page context
      page: window.location.pathname,
      userId: getCurrentUserId(),
      sessionId: getSessionId(),
    }),
    // Use keepalive so the request survives page unload
    keepalive: true,
  })
}

onLCP(reportWebVital)
onINP(reportWebVital)
onCLS(reportWebVital)
onFCP(reportWebVital)
onTTFB(reportWebVital)

Real User Monitoring with Custom RUM

Library-based RUM gives you aggregate data. Custom RUM gives you user-specific context:

// rum.ts — lightweight RUM implementation
class RUM {
  private sessionId = crypto.randomUUID()
  private marks: Map<string, number> = new Map()
  private measures: PerformanceEntry[] = []

  mark(name: string) {
    this.marks.set(name, performance.now())
    performance.mark(name)
  }

  measure(name: string, startMark: string, endMark?: string) {
    const start = this.marks.get(startMark) ?? 0
    const end = endMark ? (this.marks.get(endMark) ?? performance.now()) : performance.now()
    const duration = end - start

    this.measures.push({ name, duration, startTime: start } as PerformanceEntry)

    // Alert on slow interactions
    if (duration > 1000) {
      this.send('slow-interaction', { name, duration })
    }

    return duration
  }

  // Track route changes in SPAs
  trackRouteChange(from: string, to: string) {
    const start = performance.now()
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        const duration = performance.now() - start
        this.send('route-change', { from, to, duration })
      })
    })
  }

  private send(event: string, data: Record<string, unknown>) {
    navigator.sendBeacon('/api/rum', JSON.stringify({
      event,
      data,
      sessionId: this.sessionId,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent,
    }))
  }
}

export const rum = new RUM()
// Usage in React
function ProductPage({ productId }: { productId: string }) {
  useEffect(() => {
    rum.mark('product-page-mount')
    return () => {
      rum.measure('product-page-session', 'product-page-mount')
    }
  }, [])

  const { data, isLoading } = useProduct(productId)

  useEffect(() => {
    if (data) rum.measure('product-data-loaded', 'product-page-mount')
  }, [data])

  return <div>...</div>
}

Sentry Integration: Error Tracking in Depth

npm install @sentry/react
// main.tsx — Sentry initialization
import * as Sentry from '@sentry/react'
import { BrowserTracing } from '@sentry/tracing'

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  environment: import.meta.env.MODE,
  release: import.meta.env.VITE_APP_VERSION,

  // Performance monitoring — sample 20% of transactions in prod
  tracesSampleRate: import.meta.env.PROD ? 0.2 : 1.0,

  // Session replay — record user sessions on errors
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,

  integrations: [
    new BrowserTracing({
      routingInstrumentation: Sentry.reactRouterV6Instrumentation(
        useEffect,
        useLocation,
        useNavigationType,
        createRoutesFromChildren,
        matchRoutes,
      ),
    }),
    new Sentry.Replay({
      maskAllText: true,     // GDPR: mask sensitive text
      blockAllMedia: false,
    }),
  ],

  // Add user context when available
  beforeSend(event) {
    const user = getCurrentUser()
    if (user) {
      event.user = { id: user.id, email: user.email }
    }
    return event
  },
})

Structured Error Boundaries with Sentry

// components/ErrorBoundary.tsx
import * as Sentry from '@sentry/react'

export const ErrorBoundary = Sentry.withErrorBoundary(
  ({ children }) => <>{children}</>,
  {
    fallback: ({ error, resetError }) => (
      <div role="alert" className="error-container">
        <h2>Something went wrong</h2>
        <p>Our team has been notified. Please try again.</p>
        <button onClick={resetError}>Try again</button>
        {import.meta.env.DEV && (
          <details>
            <summary>Error details</summary>
            <pre>{error.message}</pre>
          </details>
        )}
      </div>
    ),
    onError: (error, componentStack) => {
      Sentry.captureException(error, { extra: { componentStack } })
    },
  }
)

// Wrap specific sections independently
function App() {
  return (
    <ErrorBoundary>
      <Header />
      <ErrorBoundary>
        <main>
          <Routes />
        </main>
      </ErrorBoundary>
      <Footer />
    </ErrorBoundary>
  )
}

Custom Sentry Context

// Add breadcrumbs for better error context
function handleUserAction(action: string, data: Record<string, unknown>) {
  Sentry.addBreadcrumb({
    category: 'user-action',
    message: action,
    data,
    level: 'info',
  })
}

// Manual performance spans
async function loadDashboardData() {
  const transaction = Sentry.startTransaction({ name: 'dashboard-load' })
  Sentry.getCurrentHub().configureScope(scope => scope.setSpan(transaction))

  try {
    const span = transaction.startChild({ op: 'http.client', description: 'GET /api/stats' })
    const stats = await fetchStats()
    span.finish()

    const span2 = transaction.startChild({ op: 'data.transform', description: 'process stats' })
    const processed = processStats(stats)
    span2.finish()

    return processed
  } finally {
    transaction.finish()
  }
}

Performance Budgets in CI

Performance budgets enforce that performance does not regress between deployments:

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: ['http://localhost:4173/', 'http://localhost:4173/dashboard'],
      numberOfRuns: 3,
      startServerCommand: 'npm run preview',
      startServerReadyPattern: 'Local:',
    },
    assert: {
      assertions: {
        // Core Web Vitals thresholds
        'categories:performance': ['error', { minScore: 0.85 }],
        'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'interactive': ['error', { maxNumericValue: 3800 }],
        'total-blocking-time': ['error', { maxNumericValue: 300 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],

        // Bundle size budgets
        'resource-summary:script:size': ['error', { maxNumericValue: 400_000 }], // 400KB JS
        'resource-summary:stylesheet:size': ['error', { maxNumericValue: 50_000 }], // 50KB CSS
      },
    },
    upload: {
      target: 'lhci',
      serverBaseUrl: process.env.LHCI_SERVER_URL,
      token: process.env.LHCI_TOKEN,
    },
  },
}
# .github/workflows/performance.yml
- name: Run Lighthouse CI
  run: |
    npm install -g @lhci/cli
    lhci autorun
  env:
    LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_TOKEN }}

Source Maps for Production Errors

Without source maps, production errors show minified stack traces: at f.o (app.abc123.js:1:4892). With source maps, they show the original TypeScript: at handleCheckout (src/pages/Checkout.tsx:142:18).

// vite.config.ts
export default defineConfig({
  build: {
    sourcemap: 'hidden', // Generate but don't reference in HTML
  },
})

Upload source maps to Sentry as part of your build process:

# Upload after build
npx @sentry/cli sourcemaps upload   --org your-org   --project your-project   --url-prefix '~/assets'   dist/assets

User Behavior Analysis

Understanding what users actually do complements error and performance data:

// analytics.ts — privacy-respecting behavior tracking
export const analytics = {
  // Page views (automatic with most frameworks)
  page(path: string, properties?: Record<string, unknown>) {
    if (hasConsent('analytics')) {
      sendEvent('page_view', { path, ...properties })
    }
  },

  // Feature usage
  track(event: string, properties?: Record<string, unknown>) {
    if (hasConsent('analytics')) {
      sendEvent(event, properties)
    }
  },

  // Performance-sensitive user flows
  startFlow(flowName: string) {
    const start = performance.now()
    return {
      step(stepName: string) {
        const elapsed = performance.now() - start
        sendEvent('flow_step', { flowName, stepName, elapsed })
      },
      complete() {
        const duration = performance.now() - start
        sendEvent('flow_complete', { flowName, duration })
      },
      abandon(reason?: string) {
        const duration = performance.now() - start
        sendEvent('flow_abandon', { flowName, duration, reason })
      },
    }
  },
}

// Usage in checkout flow
function CheckoutPage() {
  const flow = useMemo(() => analytics.startFlow('checkout'), [])

  const handleAddressSubmit = () => {
    flow.step('address_submitted')
    // ...
  }

  const handlePaymentSuccess = () => {
    flow.complete()
    // ...
  }
}

Putting It Together: Observability Dashboard

With these pieces in place, you have:

  • Sentry: Real-time error alerts with full context
  • Lighthouse CI: Automated performance regression detection per PR
  • RUM: Actual user experience data (not synthetic measurements)
  • Web Vitals: Core Web Vitals aggregated by page and user segment
  • Source maps: Readable production stack traces

The goal is not just to know when things break — it is to understand your users' actual experience before they tell you it is broken.