OpenTelemetry via instrumentation.ts, instrumentation-client.js for browser SDK boot, custom spans across server/edge/client, Sentry integration, useReportWebVitals for CWV shipping, and four production runbooks: TTFB regression, cache miss storm, hydration error, memory leak during rolling deploy.
Who this is for: Architects responsible for a Next.js application in production — the ones who get paged at 3am. This module is about building the observability infrastructure that turns "the site is down" into "the database connection pool exhausted at 03:14 UTC, triggered by a deployment that removed the connection limit from the Prisma config, here's the fix." That level of precision comes from traces, metrics, logs, and runbooks built before the incident, not during it.
Observability is the ability to understand a system's internal state from its external outputs. The three pillars:
Traces — the journey of a single request through your system. A trace for a product page request shows: Middleware execution (2ms), Server Component render (8ms), database query for product (45ms), database query for reviews (120ms), response sent. Traces answer "why was this specific request slow?"
Metrics — aggregated measurements over time. Request rate, error rate, p50/p95/p99 response times, cache hit rate, database connection pool size. Metrics answer "is the system healthy overall, and are things getting worse?"
None of these is sufficient alone. A slow request shows up in metrics (rising p99), is diagnosed via traces (database query taking 2s), and confirmed by logs (connection pool exhausted). The triad is the diagnostic workflow.
Custom spans appear nested inside the auto-instrumented HTTP span in your trace UI. You can see exactly where within a request the business logic executed, how long it took, and whether it threw.
OpenTelemetry traces tell you about slow requests. Sentry tells you about broken requests — the uncaught exceptions, the unhandled rejections, the React hydration mismatches.
npx @sentry/wizard@latest -i nextjs
The wizard configures Sentry automatically. What it sets up:
instrumentation.ts with Sentry SDK initialisation
Error boundary integration for React
Next.js specific configuration in next.config.ts
Source map upload for production
// instrumentation.ts (generated by wizard) export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { await import('../sentry.server.config'); } if (process.env.NEXT_RUNTIME === 'edge') { await import('../sentry.edge.config'); } }
// app/global-error.tsx (generated by wizard) 'use client';
import * as Sentry from '@sentry/nextjs'; import NextError from 'next/error'; import { useEffect } from 'react';
The global-error.tsx catches errors that bubble past all error.tsx boundaries — the last resort error handler. Without it, uncaught root-level errors show a blank page.
The metrics that matter for a Next.js application:
Infrastructure metrics (from your host): CPU utilisation Memory usage Network I/O Disk I/O (for self-hosted)
Application metrics (from your monitoring service): Request rate (requests/second) Error rate (% of requests returning 5xx) Response time p50 / p95 / p99 Cache hit rate (Full Route Cache, Data Cache) Cold start rate (serverless)
Business metrics: Successful checkouts/minute User registrations/hour Active connections (WebSocket, if applicable)
The alerting philosophy: alert on symptoms, not causes. "Error rate > 1%" is a symptom — it tells you users are experiencing failures. "Database CPU > 80%" is a cause — useful for investigation but not an emergency on its own. Symptom-based alerting reduces alert fatigue.
A minimal alert set for most applications:
Error rate (5xx) > 1% for 5 minutes → P1 incident
p99 response time > 5s for 10 minutes → P2 incident
Health check endpoint returning non-200 → P1 incident
Error rate > 0.1% for 30 minutes → P3 (monitor, not wake someone up)
A runbook is a document that answers: "what do I do when alert X fires?" Writing runbooks before incidents means the on-call engineer isn't making decisions under pressure for the first time.
1. Check the Sentry dashboard for the most common errors in the last 30 minutes - Link: https://sentry.io/organizations/your-org/issues/ - Look for: new error types, spike in existing errors
2. Check the deployment history - Link: https://vercel.com/your-project/deployments - Was there a deploy in the last 30 minutes? → likely deployment regression
3. Check the database connection pool - Link: https://your-monitoring/databases - Active connections > 95%? → see "Connection Pool Exhaustion" runbook
## Common Causes and Fixes
### Deployment Regression - Rollback: `vercel rollback [previous-deployment-url]` - Takes ~2 minutes to take effect - Root cause analysis: compare the new deploy's diff
### Database Connection Pool Exhaustion - Immediate: scale down non-critical background jobs to free connections - Check for missing `await` on Prisma queries (creates abandoned connections) - Increase `connection_limit` in `DATABASE_URL` if headroom exists
### Third-Party API Failure - Check status page for dependent services (Stripe, SendGrid, etc.) - Enable circuit breaker if available - Graceful degradation: return cached data if possible
## Escalation - 15min without progress → escalate to database team - 30min without progress → escalate to engineering lead
The format matters less than the content. What every runbook needs: the alert trigger, immediate triage steps, common causes with specific fixes, and escalation paths.
The "High Error Rate" runbook above is the generic shape. In practice, your on-call rotation needs runbooks for the specific failure modes that actually page people — the ones that don't reduce cleanly to "check Sentry, check deploys, check the connection pool." Here are four that come up constantly in Next.js production operation.
## Alert **Trigger:** p95 Time to First Byte on a key route rises significantly above its rolling baseline (the exact threshold depends on the route — a dashboard page and a static-ish product page have different normal TTFBs; alert on relative regression, not one fixed number across all routes). **Severity:** P2 (P1 if it's the homepage or checkout) **On-call response time:** 15-30 minutes
## Immediate Triage
1. Is it one route or site-wide? - Site-wide regression → points at infrastructure: CDN, origin capacity, a noisy-neighbor deploy consuming shared resources, or a DNS/network issue upstream of your application entirely. - Single-route regression → points at that route's own data fetching path. Go straight to its Server Component tree and the fetches it makes.
2. Check recent deploys against the regression's start time. TTFB regressions correlate with deploys far more often than they correlate with traffic spikes — check `git log` / deployment history before assuming it's load.
3. Check whether a Dynamic API got introduced on a route that used to be static or ISR'd. `cookies()`, `headers()`, `searchParams`, or an uncached `fetch` added anywhere in a previously-static route's render path opts the *whole route* into dynamic rendering — see this course's render-decision-tree module (A-2) for the full list of what forces this. A route that used to serve from the Full Route Cache in ~5ms now hits the origin, runs the full render, and queries the database on every single request. This is the single most common cause of a TTFB regression that isn't a database or infra problem.
## Common Causes and Fixes
### N+1 Query Newly Introduced - A loop that fetches related data per-item instead of in one batched query. - Fix: batch the query (`WHERE id IN (...)`) or use your ORM's relation loading instead of fetching inside a `.map()`.
### Third-Party API Call Added to the Render Path with No Timeout - A newly added `fetch()` to an external service (pricing API, feature flag service, recommendation engine) with no timeout means a slow or degraded third party directly becomes your TTFB regression. - Fix: add an explicit timeout (`AbortSignal.timeout(...)`), and decide whether this data belongs in the render-blocking path at all versus streamed in with `<Suspense>` after the initial shell.
### Cache Layer Stopped Hitting Due to a Changed Cache Key - A deploy changed a parameter that feeds into a Data Cache or `use cache` key (added a param, changed a default, reordered arguments in a way that affects the generated key) — every request is now a cache miss computing fresh, and the origin/database load rises accordingly. - Fix: diff the cache key inputs against the previous deploy; verify cache hit rate in your metrics dashboard dropped at the same timestamp as the regression.
### Cold Serverless Function Under Low Traffic - A route that only gets occasional traffic scales to zero between requests; every request during a quiet period pays a full cold start, which shows up as an elevated TTFB rather than a distinct "cold start" metric unless you're tracking that separately. - Fix: see the cold-start optimisation playbook in A-14 — bundle size, lazy initialization, and (for the routes that truly need it) keep-warm strategies.
## Escalation - Site-wide and correlates with a deploy → roll back immediately, don't wait to diagnose root cause first. - Single-route and it's a database query → page the on-call database owner once you've confirmed it with a slow-query log or trace, not before. - No clear cause after 20 minutes of triage → escalate to infra/platform on-call; a TTFB regression with no obvious code-level cause is often upstream of your application.
## Alert **Trigger:** Cache hit rate for a tag or route drops sharply (e.g. from ~95% to under 50%) and origin/database request volume spikes correspondingly. **Severity:** P1 if origin load threatens to cascade into an outage, otherwise P2. **On-call response time:** 15 minutes
## Immediate Triage
1. Identify what got invalidated and when. Three usual suspects: - A `revalidateTag()` or `revalidatePath()` call fired somewhere — check recent Server Action / webhook activity for anything that triggers revalidation. - A deploy changed a cache key's shape — see below. - A self-hosted deployment restarted pods and lost in-memory or filesystem-based cache state (see A-3's distributed cache handler content and A-14's ISR-on-Kubernetes section — this is exactly the "each pod has its own filesystem cache" problem those cover).
2. Check whether the drop is uniform across all pods/instances (points to a genuine invalidation event) or only on newly-deployed/restarted instances (points to lost in-memory state on a self-hosted deployment without a shared cache handler).
## Common Causes and Fixes
### Overly Broad `revalidateTag` Call - A tag like `'products'` applied broadly, then invalidated when only one product changed, wipes the cache for every product page at once instead of just the one that changed. - Fix: use more granular tags (`product:${id}`) so a single update doesn't cascade into invalidating everything sharing the coarse tag.
### Cache Key Shape Changed by a Deploy - A deploy added a parameter to a cached function (e.g. a new argument to a `use cache`-annotated function, or a changed query param read inside a cached fetch) — every old cache entry is now permanently unreachable under the new key. The data hasn't changed and nothing was explicitly invalidated, but every request is a "miss" because it's now looking under a different key entirely. - Fix: this isn't really a "fix and it recovers" scenario — the cache naturally repopulates under the new key shape as traffic replays, but the origin spike during that repopulation window is the incident. If it's severe, consider a controlled rollout instead of an instant cutover next time a cache key shape changes.
### Self-Hosted Pod Restart Losing Filesystem Cache State - Rolling deploy or autoscaling event cycles pods; each new pod starts with an empty local filesystem cache. - Fix (structural, not immediate): move to a shared Redis-backed cache handler (A-3, A-14) so cache state survives individual pod restarts. In the moment, there's no immediate mitigation beyond riding out the repopulation — this is why the structural fix matters more than the runbook here.
## Escalation - If origin request volume is high enough to risk cascading into a database outage (connection pool exhaustion, CPU saturation), shed load immediately — rate limit at the edge/CDN, or serve stale content if your cache handler supports stale-while-revalidate — before trying to diagnose the root cause further. Stopping the bleeding comes before root-causing a cache stampede. - If the storm doesn't stabilize within 15-20 minutes on its own, escalate to whoever owns the cache handler / infra layer.
## Alert **Trigger:** Rate of hydration mismatch errors (reported via Sentry or similar) increases significantly above baseline. **Severity:** P3 by default (most hydration errors are cosmetic and React recovers), escalate to P2 if interactivity is visibly broken for users. **On-call response time:** Same business day, unless interactivity is broken.
## Immediate Triage
1. Get the exact error message and, if possible, the component stack. React's hydration mismatch warnings include more detail in development than in a production build — check whether your Sentry/error-tracking integration is capturing the fuller dev-mode-style detail (some setups configure this, some don't; verify against your specific configuration) or only the minified production error.
2. Determine whether it's one component or spread across many. A single component pointing at one recently-changed file is a quick fix. Errors spread broadly across unrelated components more often point at something environmental (a browser extension, a shared layout component, a global date/locale utility).
## Common Causes and Fixes
### Environment-Dependent Rendering Without Guards - `Date.now()`, `Math.random()`, or a `typeof window !== 'undefined'` branch used directly in render output produces different output on the server than on the client re-render. - Locale/timezone-dependent formatting (`toLocaleDateString()`, etc.) can differ between the server's locale/timezone and the client's, especially if the server isn't explicitly pinned to UTC or a fixed locale. - Fix: move non-deterministic or environment-dependent values into `useEffect` (so they only apply after hydration) or pass them from the server explicitly rather than computing them independently on both sides.
### Browser Extension Injecting DOM Before Hydration - Password managers, ad blockers, and accessibility extensions sometimes inject attributes or elements into the DOM before React hydrates, causing a mismatch that has nothing to do with your code. - Fix: often nothing to fix on your end — verify by reproducing in a clean browser profile with no extensions. `suppressHydrationWarning` on the specific affected element is the documented escape hatch when the mismatch is known to be extension-caused and cosmetic.
### Invalid HTML Nesting - Browsers silently restructure invalid nesting (e.g. a `<div>` inside a `<p>`, or a `<tr>` not inside a `<table>`/`<tbody>`) before React gets a chance to hydrate against it, so the DOM React expects and the DOM that's actually there diverge. - Fix: validate the JSX against HTML nesting rules — this is a common one when a design system component (a "Text" or "Typography" wrapper) renders a `<p>` internally and gets nested inside another such wrapper.
## Escalation - Cosmetic mismatch (a class name differs, a whitespace difference) with no reported loss of interactivity → track it, fix in the next normal deploy cycle, no page needed. - Mismatch that breaks interactivity — React can recover from many hydration errors by discarding and re-rendering the mismatched subtree client-side, but not all of them; a severe enough mismatch can leave a component non-interactive or throw further errors downstream. If users are reporting broken buttons/forms correlated with the error spike, treat it as P2 and escalate to the owner of the affected component.
## Alert **Trigger:** Memory usage per pod/instance climbs steadily over the deploy window instead of stabilizing after the initial ramp-up, eventually triggering OOM kills or automatic restarts. **Severity:** P1 (OOM kills mean dropped requests and, if it hits enough instances at once, an outage). **On-call response time:** Immediate.
## Immediate Triage
1. Check whether memory is climbing on ALL instances uniformly, or only on instances mid-drain (the old instances being phased out during the rolling deploy). - All instances, including fresh ones past their initial warm-up → suggests a real leak in the newly deployed code. - Only draining old instances → often not a leak at all, but slow connection draining: old pods are still holding open connections (database, WebSocket, long-running requests) while new traffic goes to new pods, and the platform hasn't force-killed them yet. Verify against your platform's drain/termination grace period configuration.
2. Correlate the memory climb's start time precisely against the rolling deploy timeline — did it start the moment the new version's pods came up, or was it already trending upward before this deploy (in which case this deploy isn't the cause, just where it crossed the OOM threshold)?
## Common Causes and Fixes
### Unbounded In-Memory Cache - An in-memory cache (a `Map`, an LRU without a max size configured, a memoization layer) with no eviction policy grows for as long as the process runs, and a rolling deploy's fresh instances simply haven't hit the ceiling yet on instances that have been up longer. - Fix: cap the cache size explicitly (max entries or max memory), or move it to an external cache (Redis) that isn't bound to a single process's heap.
### Per-Request Event Listeners Never Removed - Code that calls `.on(...)` (an EventEmitter, a pub/sub client, a WebSocket server's connection handler) once per request but never calls the corresponding `.off()`/`.removeListener()` accumulates listeners indefinitely — each one holding a closure over that request's data. This is the same class of leak this course's EventEmitter/listener content addresses, just surfacing here as a rolling-deploy symptom because a fresh deploy's pods haven't yet accumulated enough listeners to OOM, while long-running old pods have. - Fix: ensure every `.on()` has a matching cleanup path, ideally scoped so it's structurally impossible to forget (a `try/finally`, or a request-scoped wrapper that registers cleanup automatically).
### Database Connection Pool Not Closed on Drain - Old instances being drained during the rolling deploy hold their connection pool open indefinitely instead of closing it as they shut down, so connections (and the memory backing them) accumulate instead of being released. - Fix: hook into your platform's shutdown signal (`SIGTERM`) to close the pool gracefully before the process exits, rather than relying on the process being killed to release resources.
## Escalation - If OOM kills are actively dropping user requests right now → roll back the deploy immediately. Don't spend the incident window trying to patch forward under pressure; stabilize first, diagnose from logs/heap snapshots after. - If memory is climbing but hasn't yet caused an OOM kill, and the trend is slow enough to have some runway → it's reasonable to let the deploy finish, capture a heap snapshot from an affected instance, and patch forward with a fix rather than rolling back — but set an explicit time box for this decision rather than watching it drift toward an outage. - Kubernetes/container-platform specific: verify your pod's `terminationGracePeriodSeconds` gives in-flight requests and connection draining enough time to complete before `SIGKILL` — a grace period that's too short can itself look like "instances are leaking memory" when it's really connections being severed mid-drain rather than closed cleanly.
instrumentation.ts (covered above) runs on the server and Edge runtime. It has no reach into the browser — by the time your React components hydrate on the client, instrumentation.ts has long since finished running on a different machine entirely. For client-side observability (browser error tracking, session replay, real-user monitoring) you need code that runs in the browser, as early as possible, before the rest of your application code executes.
That's what instrumentation-client.js (or .ts) is for. Placed at the root of your project (same level as instrumentation.ts), Next.js loads it before any other client-side code runs, so your browser SDK is capturing errors and events from the very first paint rather than missing whatever happened before some useEffect deep in the tree got around to initializing it.
// instrumentation-client.ts import * as Sentry from '@sentry/nextjs';
This is distinct from the sentry.client.config.ts pattern the Sentry wizard generated in older Next.js versions — instrumentation-client.js is the framework-native hook for "run this in the browser before anything else," and as of Next.js 15.3 it's the documented way to boot a browser-side SDK. If you're on an older Next.js version, verify whether this hook is available to you or whether you're still on the manual _app/root-layout-import pattern.
Core Web Vitals (LCP, CLS, INP, and friends) are measured in the browser — they describe what an actual user's device experienced, which is not something a server-side trace can see. Next.js exposes them via the useReportWebVitals hook from next/web-vitals.
// app/components/WebVitals.tsx 'use client';
import { useReportWebVitals } from 'next/web-vitals'; import { trace } from '@opentelemetry/api';
export function WebVitals() { useReportWebVitals((metric) => { // Ship it as a custom span attribute on the active trace, tying the // client-side metric back to the same trace/log correlation model // used elsewhere in this module const span = trace.getActiveSpan(); span?.setAttribute(`web-vital.${metric.name.toLowerCase()}`, metric.value);
// Or ship it directly to your metrics backend as its own data point fetch('/api/vitals', { method: 'POST', body: JSON.stringify(metric), keepalive: true, // survives page unload, since CLS/LCP often fire late }); });
return null; }
Mount it once near the root of your app (e.g. in the root layout, as a small client component — it renders nothing, it just registers the reporting callback):
// app/layout.tsx import { WebVitals } from './components/WebVitals';
The metric object includes name ('LCP', 'CLS', 'INP', 'FCP', 'TTFB'), value, and id (a unique identifier per page load, useful for deduplicating if the metric fires more than once). Route these into whatever's already ingesting your traces and metrics — a dedicated /api/vitals Route Handler that forwards to your monitoring service, or directly as a custom span attribute as shown above — rather than standing up a separate pipeline just for Web Vitals. The value of tying them into the same backend as your traces and logs is that a CLS regression and a deploy that changed the hero image's dimensions show up in the same dashboard, not two disconnected tools.
Structured logs (JSON format) are parseable by log aggregation services (Datadog, Grafana Loki, CloudWatch Logs Insights). They're queryable: "show me all logs where userId is 123 and level is error."
// lib/logger.ts import pino from 'pino';
export const logger = pino({ level: process.env.LOG_LEVEL ?? 'info', transport: process.env.NODE_ENV === 'development' ? { target: 'pino-pretty' } // human-readable in dev : undefined, // JSON in production base: { service: 'nextjs-app', version: process.env.npm_package_version, environment: process.env.NODE_ENV, }, });
// In a Server Action import { logger } from '@/lib/logger'; import { trace } from '@opentelemetry/api';
The traceId in every log entry is the correlation key — in your observability platform, you can click from a log entry to the trace that generated it. This is the debugging superpower: see the error in Sentry, look up the trace in your tracing service, correlate the logs via traceId, understand exactly what happened.
A-16 covers the Router Cache — the client-side cache of RSC payloads that governs what happens when you click a link you've already visited, including why revalidatePath on the server doesn't always show up instantly on the client without a router.refresh(). A-17, the final module, covers error architecture and recovery patterns: how error.tsx, global-error.tsx, and error boundaries fit together, and how to correlate a production error back to its cause.
Knowledge Check
Which of the following best describes the role of "Traces" in the three pillars of observability?
What is a key advantage of including a traceId in structured logs?
According to the module, what is the recommended philosophy for setting up alerting?
During a rolling deploy, memory usage is climbing — but only on the OLD instances that are being drained, not on the freshly deployed ones. According to the Memory Leak During Rolling Deploy runbook, what does this pattern most likely indicate?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.