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.
The Three Pillars of Observability
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.
OpenTelemetry in Next.js
OpenTelemetry (OTel) is the industry standard for trace and metric instrumentation. Next.js 13+ has built-in OTel support.
The result: every request automatically generates a trace showing all the work it triggered. No manual span creation required for the common cases.
Custom Spans for Business Logic
The auto-instrumentation covers infrastructure — database, HTTP. For business logic, add custom spans:
ts
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.
Sentry for Error Tracking
OpenTelemetry traces tell you about slow requests. Sentry tells you about broken requests — the uncaught exceptions, the unhandled rejections, the React hydration mismatches.
bash
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
ts
tsx
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.
Metrics and Alerting
The metrics that matter for a Next.js application:
text
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)
The Runbook Template
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.
markdown
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.
Runbook: TTFB Regression
markdown
Runbook: Cache Miss Storm
markdown
Runbook: Hydration Error Spike
markdown
Runbook: Memory Leak During Rolling Deploy
markdown
instrumentation-client.js — Booting the Browser SDK
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.
ts
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.
useReportWebVitals — Shipping Core Web Vitals to Your Stack
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.
tsx
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):
tsx
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 Logging
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."
ts
ts
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.
Where We Go From Here
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.
// instrumentation.ts (generated by wizard)exportasyncfunctionregister(){if(process.env.NEXT_RUNTIME==='nodejs'){awaitimport('../sentry.server.config');}if(process.env.NEXT_RUNTIME==='edge'){awaitimport('../sentry.edge.config');}}
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)
# Runbook: High Error Rate (5xx)## Alert**Trigger:** Error rate > 1% for 5 minutes
**Severity:** P1
**On-call response time:** 15 minutes
## Immediate Triage1. 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
# Runbook: TTFB Regression## 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 Triage1. 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.
# Runbook: Cache Miss Storm## 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 Triage1. 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.
# Runbook: Hydration Error Spike## 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 Triage1. 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.
# Runbook: Memory Leak During Rolling Deploy## 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 Triage1. 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.
// app/components/WebVitals.tsx'use client';import{ useReportWebVitals }from'next/web-vitals';import{ trace }from'@opentelemetry/api';exportfunctionWebVitals(){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 moduleconst span = trace.getActiveSpan(); span?.setAttribute(`web-vital.${metric.name.toLowerCase()}`, metric.value);// Or ship it directly to your metrics backend as its own data pointfetch('/api/vitals',{ method:'POST', body:JSON.stringify(metric), keepalive:true,// survives page unload, since CLS/LCP often fire late});});returnnull;}
// app/layout.tsximport{WebVitals}from'./components/WebVitals';exportdefaultfunctionRootLayout({ children }:{ children:React.ReactNode}){return(<htmllang="en"><body><WebVitals/>{children}</body></html>);}
// lib/logger.tsimport pino from'pino';exportconst 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 Actionimport{ logger }from'@/lib/logger';import{ trace }from'@opentelemetry/api';exportasyncfunctioncreateOrder(formData: FormData){const span = trace.getActiveSpan();const traceId = span?.spanContext().traceId;const log = logger.child({ traceId,// correlate logs to traces userId: session.user.id, action:'create-order',}); log.info({ productId: formData.get('productId')},'Creating order');try{const order =await db.orders.create({...}); log.info({ orderId: order.id },'Order created successfully');return order;}catch(error){ log.error({ error },'Order creation failed');throw error;}}