Skip to content
Chapter 92Lesson 1

Sentry: capture, releases, and breadcrumbs

Wiring Sentry as the error-monitoring surface of your SaaS, so production exceptions arrive grouped, source-mapped, release-tagged, and tied to the user who hit them.

A createInvoice Server Action throws in production. A required field came back from Stripe as null, a property access blew up, and the action did exactly what you built it to do back in the error-handling chapter: it caught the throw, returned a Result.err, and the user saw a calm generic toast instead of a stack trace. The user side is handled. The operator side is a promise you haven’t kept yet.

Right now that failure exists in exactly one place: Vercel’s function output, as a single minified line like Function.t [as h] (chunk-abc123.js:1:42). No user is attached, no org, no plan, and no link to the deploy that introduced it. To answer “what happened?” you’d open the Vercel dashboard, find the right function, scroll to the right invocation, and reconstruct the rest from memory. When you wrote that catch, you put a Sentry.captureException(error) line right next to your logger.error(...) and left a note that the wiring waited for a later chapter. This is that chapter, and that line is where we start. You’re finishing a sentence, not opening a new topic.

By the end of this lesson, two deliberate throws, one from a Server Action and one from a client component, will arrive in Sentry grouped, with a readable stack trace, the correct release tag, and the user and org context attached. One rule makes all of that work, and we’ll keep coming back to it: a server-side error reaches Sentry by exactly one of two routes, and you should always be able to say which.

What Sentry gives you that a log line can’t

Section titled “What Sentry gives you that a log line can’t”

You’re about to install two observability tools in this chapter, Sentry here and structured logs next, so the first decision is what each one is for. A log line, which you’ll build in the next lesson, is a flat record. It’s there if you wrote it, and it helps you if you already know what to filter for. That’s the right shape for “replay the request,” and the wrong shape for “something is broken and I don’t yet know what.”

A Sentry event is the same failure, enriched along five axes that a raw log line doesn’t have:

  • Grouped by fingerprint. Forty-seven instances of the same bug collapse into one issue with a count, instead of forty-seven lines you have to notice are identical.
  • Source-mapped. The minified production frame is rebound to your original .ts source, file and line.
  • Release-tagged. Every event carries the deploy it shipped in, so a regression points at the commit that introduced it.
  • User- and org-tagged. You can filter to “every error this user hit this week.”
  • Breadcrumb-trailed. The event arrives with a trail of what happened in the seconds before the throw.

Those five are the spine of this lesson, and each section below wires one of them. Keep in mind the framing the chapter opens on: errors and logs are two surfaces of one incident. They aren’t competitors. Later in this chapter they’ll share a single requestId, so you can stand in a Sentry event, copy that ID, and jump straight to the matching log lines. Today we build the error surface; keep that join in the back of your mind.

The figure below is the whole pitch: the same failure, before and after.

Vercel function log
Sentry issue
chunk-abc123.js:1:42
Stack trace
createInvoice.ts:34
one line per occurrence
Grouping
47 occurrences → 1 issue
none
Release
tagged a1b2c3d
none
User / org
user_8fa… · org_pro_12
none
Trail before the throw
navigate → load invoice → submit
The same production failure, as Vercel sees it and as Sentry sees it.

Why Sentry, and why it’s the default here. Sentry is the default error monitor for a 2026 Next.js stack. The alternatives exist: Bugsnag, Rollbar, Highlight, and Honeybadger all capture exceptions competently. But for the SaaS this course builds, there’s no reason to reach for one of them. Sentry wins on the thing that matters most for this stack: a first-party-quality Next.js SDK that folds release tracking and source-map upload into a single wizard and hooks the App Router’s instrumentation file directly. Because that integration does so much for you, the rest of this lesson is short on setup and long on judgment.

One scope note, so you don’t overbuy. Sentry also ships session replay. We won’t turn it on, because the next chapter picks PostHog for replay, and running two replay products side by side means paying twice for one capability. When a feature in this lesson has a cheaper owner elsewhere in the course, it stays off here.

The first decision is whether to hand-wire Sentry or let its wizard do it. Use the wizard. It writes a complete, working baseline in one command, and hand-wiring the same files buys you nothing but the chance to get a config path wrong. Using the wizard is not the same as treating its output as magic, though. You’re going to own these files, so we’ll walk every one it touches and label what’s load-bearing.

Terminal window
npx @sentry/wizard@latest -i nextjs

The wizard asks you to log in, pick (or create) a Sentry project, and then it writes the files below. It creates the config files, wraps your next.config.ts, drops a git-ignored env file holding a build-time auth token, and adds the source-map upload step to your build. Here’s everything it touches at a glance; the highlighted entries are new.

  • Directorysrc/
    • Directoryapp/
      • global-error.tsx error boundary, calls Sentry.captureException
    • instrumentation.ts Next.js hook: register() + onRequestError
    • instrumentation-client.ts browser SDK: Sentry.init
    • sentry.server.config.ts Node SDK: Sentry.init
    • sentry.edge.config.ts edge runtime SDK: Sentry.init
  • next.config.ts wrapped in withSentryConfig
  • .env.sentry-build-plugin auth token, git-ignored

This is more files than people expect, and the reason is worth pausing on: there is no single place where Sentry “starts.” Sentry.init, the call that boots the SDK with your config, runs in three separate files, one per runtime your app executes in:

  • instrumentation-client.ts boots the SDK in the browser.
  • sentry.server.config.ts boots it in the Node server runtime.
  • sentry.edge.config.ts boots it in the edge runtime.

Then instrumentation.ts is the glue. It’s a Next.js file with a special name, and it does two jobs. Its register() function runs once when the server boots and imports the right server config based on which runtime it’s in. It also exports onRequestError, the hook that catches server-side throws. That export drives this whole lesson, so let’s look at exactly what the wizard puts there.

import * as Sentry from '@sentry/nextjs';
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');
}
}
export const onRequestError = Sentry.captureRequestError;

The SDK import. It now resolves to the real @sentry/nextjs package, the same import the captureException stub from the error-handling chapter referenced.

import * as Sentry from '@sentry/nextjs';
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');
}
}
export const onRequestError = Sentry.captureRequestError;

register() runs once at server boot and lazily imports the config for whichever runtime is live, keyed off NEXT_RUNTIME. This is why Sentry.init lives in those separate config files rather than here.

import * as Sentry from '@sentry/nextjs';
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');
}
}
export const onRequestError = Sentry.captureRequestError;

The onRequestError export. This one line is the entire uncaught-error path, and the next section works through it.

1 / 1

A few calls in the wizard’s output are load-bearing, and a couple are yours to adjust:

  • Keep the tunnel route. Inside withSentryConfig the wizard sets a tunnelRoute . Ad-blockers recognize and block requests to Sentry’s default ingest endpoint, which means client-side events silently vanish for a chunk of your users. The tunnel route makes the request look same-origin. Leave it on; it’s a default that protects you.
  • Delete the example page. The wizard adds a route with a deliberate-error button so you can fire a first test event. It’s useful once, but a liability if it ships. Smoke-test with it, then delete it.
  • One DSN , both sides. The client and server SDKs share a single DSN. You may see advice to configure a separate “client DSN.” Don’t: it’s extra config to keep in sync for no benefit. One project, one DSN.

If you want to watch the whole wizard run before you run it yourself, Sentry’s own walkthrough does exactly that, and it’s the closest thing to a video companion for this lesson.

Here’s the first of the two capture paths, and the reason we just looked closely at that one export line. onRequestError is a Next.js framework hook (it needs @sentry/nextjs 8.28 or newer and Next 15+). Next.js calls it whenever an error bubbles all the way up to its boundary: an uncaught throw in a Server Component, a route handler, or a Server Action that doesn’t catch its own error. The wizard binds that hook to Sentry.captureRequestError, so every such throw gets reported with the request’s context already attached.

State it as a rule, because forgetting it is the most common wiring mistake there is: without this export, server-side throws that reach the framework boundary never reach Sentry. It is the entire uncaught path. Delete that one line and an entire class of production errors goes dark, and it goes dark silently, because nothing breaks. Events just stop arriving.

Make the boundary concrete with two throws. A Server Component that throws, say a page.tsx that hits a null it didn’t guard, bubbles up to Next.js, which renders the nearest error.tsx for the user and fires onRequestError, so the event lands in Sentry. But your authedAction wrapper from the forms-and-actions chapters is built to catch its own throw and return a Result.err. It never lets the error reach the framework boundary, so onRequestError never fires for it. That’s exactly why there has to be a second path, which we cover next.

The diagram below lays out the two paths before we name the second one. Scrub through it.

Uncaught — framework boundary
throw
Next.js boundary
onRequestError
Caught & handled — your wrapper
throw
catch in authedAction
Sentry.captureException
Result.err → user
Sentry issue
An uncaught throw in a Server Component, route handler, or Server Action bubbles up to Next.js's framework boundary.
Uncaught — framework boundary
throw
Next.js boundary
onRequestError
Caught & handled — your wrapper
throw
catch in authedAction
Sentry.captureException
Result.err → user
Sentry issue
Next.js binds onRequestError to Sentry — the uncaught path is wired for you by the wizard, no code of yours required.
Uncaught — framework boundary
throw
Next.js boundary
onRequestError
Caught & handled — your wrapper
throw
catch in authedAction
Sentry.captureException
Result.err → user
Sentry issue
But authedAction catches its own throw to return Result.err, so the error never reaches the boundary — onRequestError never fires, and nothing reaches Sentry.
Uncaught — framework boundary
throw
Next.js boundary
onRequestError
Caught & handled — your wrapper
throw
catch in authedAction
Sentry.captureException
Result.err → user
Sentry issue
The fix: call captureException inside the catch. Both lanes now converge on the same Sentry issue — and Result.err still returns to the user.

captureException inside your wrappers: the caught path

Section titled “captureException inside your wrappers: the caught path”

This is where the stub from the error-handling chapter becomes real. The problem it solves is a direct consequence of good design. Your authedAction and authedRoute wrappers exist to catch the throw: that’s how the user gets a safe message instead of a stack trace, and how the operator detail stays out of the response. But a caught error is, by definition, an error that never bubbled to the framework boundary, so onRequestError will never see it. The wrapper’s catch is the only place the caught path can be covered.

Recall the shape of that catch from the error-handling chapter. It normalizes the unknown error, writes an operator-facing log line, and then calls Sentry, the line we’re here to wire, before returning the user-safe result. The two tabs below are the whole change in miniature: the stub you wrote, and the enriched version you’re replacing it with.

} catch (e) {
const error = ensureError(e);
logger.error(
{ action: fn.name, userId, orgId, role, input: redact(input.data), err: error },
'action failed',
);
Sentry.captureException(error);
return mapError(error);
}

The wire exists, but the event is bare. It arrives grouped and source-mapped, but with no record of which action threw or who hit it.

Now the rule to commit to memory, stated in both directions so you can apply it to any throw: every catch-and-handle seam calls captureException; every uncaught throw rides onRequestError. Here is the payoff of having built the wrapper in the first place. Because the split lives in the wrapper, you wire captureException in exactly one place, and every action that goes through authedAction inherits it for free. The flip side is the warning: an action that bypasses authedAction and does its own thing skips not just fail-closed authorization and the message split, but Sentry capture too. The wrapper is now load-bearing for observability, not just for safety.

One name to know and then set aside: withServerActionInstrumentation. It’s Sentry’s wrapper that puts a performance span around an action and groups by action name. Reach for it when a specific action needs a trace. Basic error capture, the thing we care about today, works without it, so we’re not wiring it here.

Go back to the diagram from the last section and scrub to its final frame. Both lanes now reach the same Sentry issue: the uncaught lane via onRequestError, the caught lane via the captureException you just added. That convergence is the two-path rule made literal.

Before moving on, prove to yourself that you can route any throw. Drag each one to the path that carries it to Sentry.

Each throw below reaches Sentry by one path — or by neither server path. Sort them. Drag each item into the bucket it belongs to, then press Check.

→ onRequestError Bubbles to the framework boundary
→ manual captureException Caught and handled in a wrapper
→ neither (client SDK's job) Never touches the server
An unguarded null access throws in a Server Component’s render
authedAction catches a failed DB write and returns Result.err
A route handler rethrows after logging, letting it bubble
A webhook handler catches a signature mismatch and returns a 400
An unhandled promise rejection in a client component’s onClick

Source maps and releases: a readable, dated stack

Section titled “Source maps and releases: a readable, dated stack”

The capture paths get the event to Sentry. The next two enrichments make the event worth reading. They travel together, both are wired by the wizard, and each needs you to verify exactly one thing, so this section is about verification rather than theory.

Source maps solve the minified-frame problem. Production JavaScript is minified, so the raw frame really does read chunk-abc123.js:1:42, which tells you nothing. A source map lets Sentry rebind that frame to createInvoice.ts:34. The wizard’s build step uploads your source maps on every production build so Sentry can do that rebinding. The one thing to verify is that the build-time authToken is set and the org and project slugs in withSentryConfig point at the right Sentry project. Know the boundary too: CI uploads source maps; your local dev build does not. That’s deliberate, because uploading on every local build would be slow and pointless. Don’t expect symbolicated traces from next dev.

Releases solve the “when did this start?” problem. A release tags every event with the deploy it shipped in. The current guidance is to derive the release name from the commit SHA, so it lines up with the source maps from that same commit. On Vercel the SHA is handed to you as the VERCEL_GIT_COMMIT_SHA environment variable. The payoff turns a 2am regression hunt into a one-liner: Sentry tells you “first seen in release a1b2c3d by Jordan,” and you’ve narrowed the bug to a single deploy and author without running git bisect at all.

There’s a smoother way to wire all of this than copying tokens by hand. The Sentry–Vercel integration, connected once from either dashboard, auto-injects SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN, and NEXT_PUBLIC_SENTRY_DSN into your Vercel project and notifies Sentry on every deploy. That’s the recommended path, and it makes the manual token step disappear. The manual env route is the fallback for when you’re not on Vercel.

Here’s what those two enrichments buy you, in one picture: the same frame before and after.

Before — what the server emits
Function.t [as h] (chunk-abc123.js:1:42)
at r (chunk-abc123.js:1:1180)
no release
After — what Sentry shows
createInvoice (src/app/_lib/actions/create-invoice.ts:34:11)
at authedAction (src/lib/auth/authed-action.ts:58:9)
release a1b2c3d · first seen 2d ago
Source maps rebind the frame to your source; the release tag dates it to a deploy.

User, org, and tags: making errors filterable

Section titled “User, org, and tags: making errors filterable”

Source maps make one event readable. This enrichment makes a pile of events queryable, and it’s the one place this lesson touches PII, which we’ll handle by reference rather than re-arguing.

The core call is Sentry.setUser. After authentication, you attach the actor to the Sentry scope, and from then on every event in that request carries it:

Sentry.setUser({ id: ctx.user.id, email: ctx.user.email });
Sentry.setTag('orgId', ctx.orgId);

With the user set, Sentry groups errors per person and lets an operator filter to “every error user X hit this week,” which is the raw input to the bug-SLA dashboards a support team lives in. Note what’s in that call: an internal ID and an email. Here, email is operator-safe context, not PII to redact. That isn’t a new decision. The error-handling chapter already drew the user/operator line, and it put internal IDs and email addresses on the operator side precisely so support and incident response can use them. We’re spending that decision, not re-making it.

Tags are the other half. A tag is a low-cardinality label you filter on, such as plan: 'pro', feature: 'invoicing', or seam: 'authedAction'. The discipline is one sentence: tags are filter dimensions, so they must be low-cardinality. Two corollaries fall out of it. Never put a secret in a tag, because tags are indexed and visible. And never put a high-cardinality one-off in a tag either: a requestId, a full URL, anything with effectively unbounded distinct values. Those belong in extra / context, which Sentry stores on the event but doesn’t try to index as a filterable dimension. That word, cardinality , is the instinct to build here, because the next exercise drills it.

That requestId is worth flagging now even though we’re not building it yet. In the next lesson it becomes the join key between a Sentry event and your logs. For today, just know that it rides along as context rather than a tag, and that it’s the future pivot anchor. As for where you set all this, prefer setting the user and tags at the request entry, or inside the wrapper right next to the captureException call, so every event in the request inherits the context without you having to remember at each call site.

Sort these six fields the way you’d treat them on a Sentry event.

Each field below could end up on a Sentry event. Where does it belong? Drag each item into the bucket it belongs to, then press Check.

Tag Low-cardinality filter dimension
Context / extra High-cardinality, one-off, not a filter
Never send Secret — keep it off the event
plan (free / pro / enterprise)
orgId
requestId
The Stripe secret API key
feature name (invoicing)
The full request URL with query string
Section titled “Breadcrumbs: the trail that led to the throw”

User and tags tell you who and what kind. Breadcrumbs tell you what just happened. A breadcrumb is one step in the story leading up to the throw. Sentry auto-captures some for you, such as navigation, fetch calls, and console output, and they all ship attached to the next error that fires, then they’re dropped. They answer the question a stack trace can’t: “what was the user doing right before this broke?”

This is the section most likely to trip you up, because breadcrumbs look like logs but are not. Hold these three apart, because the chapter leans on the distinction:

  • Breadcrumbs are per-event, ephemeral context that ships with the error and is gone once it fires. No error, no breadcrumbs.
  • Logs (next lesson) are persistent, queryable lines that exist whether or not anything ever throws.
  • The audit log (from the security chapter) is durable domain events kept for compliance.

Three different stores, three different lifetimes, three different questions. Confusing the first two is the classic beginner mistake: a breadcrumb is not “a log that only Sentry can see.”

You add custom breadcrumbs where the stack trace alone won’t explain the failure:

Sentry.addBreadcrumb({
category: 'invoice',
message: 'Loaded invoice for billing',
data: { invoiceId },
});

The decision rule is sharp: add a breadcrumb where the failure context isn’t recoverable from the stack. The stack tells you which line, but not which invoice, which webhook event, or which step of a multi-step action was in flight. So the high-value places are webhook handlers (drop the event id and type), background jobs (the job id and the shape of its input), and multi-step actions (which step ran before it broke). Where the stack already says everything, skip the breadcrumb: one on every function entry is just noise that buries the signal.

The diagram below makes the contrast literal: a bounded breadcrumb trail terminating in an exception, set beside the open-ended log stream that exists regardless.

Breadcrumbs (this event)
navigate /invoices/123
fetch GET /api/invoice/123 200
breadcrumb: loaded invoice for billing
click "Pay"
TypeError: cannot read 'amount' of null
Bounded · ships with this one error · then dropped
Logs (always on)
14:31:58 info invoice.list org_12
14:32:01 info invoice.get org_12
14:32:04 error invoice.pay org_12
14:32:09 info invoice.list org_44
Unbounded · runs whether or not anything throws
Breadcrumbs are a bounded trail that ships with one error. Logs run whether or not anything throws.

Lock in the distinction with a quick round.

Each claim is about which of the three stores — breadcrumbs, logs, or the audit log — fits the job. Mark each statement True or False.

Breadcrumbs are queryable across all of your events, like a database you can filter and search after the fact.

Breadcrumbs are per-event and ephemeral — the trail attaches to the one error that fires next, then it’s gone. The persistent, queryable store is the log stream (next lesson), not breadcrumbs.

A breadcrumb is dropped after its error fires.

The trail ships with the error and is cleared once it does. No error, no breadcrumbs — they only ever exist as context attached to a captured event.

To record a successful payment for a compliance audit, you add a breadcrumb.

That’s the durable audit log’s job (from the security chapter) — domain events kept for compliance. Breadcrumbs vanish after an error fires and don’t exist at all if nothing throws, so a successful payment would never be recorded by one.

A log line exists even when no error is thrown.

Logs are persistent records that exist whether or not anything ever fails — that’s exactly the shape that lets you “replay the request” after the fact. Breadcrumbs, by contrast, only materialise alongside an error.

Sentry on the client, and from your error boundaries

Section titled “Sentry on the client, and from your error boundaries”

So far every throw we’ve routed has been server-side. But the contrast figure at the top promised a client error too, and the routing exercise had one item, the unhandled rejection, that landed in neither server path. This section closes that loop by covering the other surfaces a throw can come from.

The client SDK is its own engine. instrumentation-client.ts runs Sentry in the browser, while sentry.server.config.ts and sentry.edge.config.ts run it on the server. They share one DSN and the same release tag, and that shared release tag is the quietly important part. Because the release tag matches, a client error and a server error from the same deploy group under the same release, so you can reason about a bad deploy as one thing instead of two disconnected piles. The client SDK auto-captures unhandled promise rejections and console errors (via its captureConsoleIntegration); the server SDK captures via onRequestError plus the manual captureException you wired. That’s the answer to the exercise item that fit neither server path: an unhandled rejection in a client component is the client SDK’s job, and it’s already handled.

Your error boundaries are the second surface. Back in the App Router chapter you built error.tsx and global-error.tsx, the components Next.js renders when a render throws, each receiving the thrown error as a prop. The wizard wires Sentry.captureException(error) into global-error.tsx’s useEffect, so the top-level boundary reports. But here’s the omission that bites people: it does not wire your per-segment error.tsx files. Those are yours to add. A segment boundary that doesn’t call captureException swallows its error as far as Sentry is concerned.

One detail makes the segment-boundary capture correct rather than just present: the digest. Next.js attaches a digest to errors that originated on the server. Thread it through when you capture from error.tsx, and the client boundary’s report links up with the server-side event instead of grouping as an unrelated client error. A digest is high-cardinality, so it rides as context on the event rather than a tag, exactly the discipline from the last two sections.

'use client';
import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="en">
<body>{/* fallback UI */}</body>
</html>
);
}

Given. The wizard covers the top-level boundary, the one that catches errors the root layout can’t.

beforeSend: the redaction floor, and keeping the quota sane

Section titled “beforeSend: the redaction floor, and keeping the quota sane”

Two closing concerns, both versions of one idea: don’t let the tool you just installed hurt you. The first is safety, meaning don’t leak. The second is economics, meaning don’t overspend.

Redaction, via beforeSend. beforeSend is a hook that runs on every event right before it’s sent, your last chance to strip anything sensitive the SDK might have swept up incidentally, such as request bodies on transactions or a query string carrying a token. The course’s posture is to strip password, token, apiKey, and Authorization from event request data, and to let user emails and IDs through, because those are operator-side by the error-handling chapter’s decision. The thing to internalize is that this is the same redaction posture as your logger: one denylist concept, two enforcement points. Sentry enforces it in beforeSend; pino enforces it in its redact config (a later lesson in this chapter owns the full denylist and the reasoning behind it). You’re not inventing a second policy, you’re applying the same one at a second door.

Sampling. Not every kind of event costs the same. Error events are cheap, while performance traces and session replays cost meaningfully more. This chapter’s floor is deliberately simple: 100% of errors, 0% of traces, 0% of replays. You’ll raise trace sampling when performance traces earn their weight (the performance chapter), and replay stays off because PostHog owns it next chapter. Here’s the part not to be confused by: Sentry’s own docs and the wizard seed tracesSampleRate: 0.1 for production. We’re turning that down to 0 on purpose, because traces are owned later and this chapter’s job is errors only. That’s an intentional course choice, not a mistake; you’re overriding a sensible default for a reason.

sentry.server.config.ts
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
release: process.env.VERCEL_GIT_COMMIT_SHA,
tracesSampleRate: 0, // raise when traces earn weight (perf chapter)
replaysSessionSampleRate: 0, // replay owned by PostHog next chapter
beforeSend(event) {
const headers = event.request?.headers;
if (headers) {
delete headers.Authorization;
delete headers.Cookie;
}
return event;
},
});

Cost and quota. Sentry’s free tier is roughly 5k errors a month, and the paid plan starts where the volume earns it. What you watch for is the small number of shapes that can burn a month’s quota in an afternoon: a tight loop calling captureException, a webhook signature mismatch that fires on every single retry, or a missing-tag error that fragments into a high-cardinality pile of near-duplicate groups. Two relief valves, named so you know they exist: level: 'warning' for events you want tracked but that aren’t page-worthy, and Sentry.withScope(scope => scope.setFingerprint([...])) to force known-noisy errors to group together. Deep fingerprinting is its own topic, so one mention is enough here.

The introduction promised that a deliberate throw from both a Server Action and a client component would arrive grouped, readable, release-tagged, and context-attached. Close the loop by proving it, rather than trusting that the wizard said it worked. This is also the shape the project at the end of this unit will automate, so it’s worth doing by hand once.

  1. Throw from a Server Action. Temporarily make an authedAction throw (or use the wizard’s example route once). Trigger it from the UI and confirm an event appears in Sentry. (Wired by: the caught path → captureException.)

  2. Throw from a client component. Throw in a client onClick handler. Confirm a second event appears. (Wired by: the client SDK.)

  3. Confirm grouping. Fire the same throw a few times and confirm the occurrences collapse into one issue with a count, not separate issues.

  4. Read the stack. Open the issue (from a production/preview deploy) and confirm the stack points at your .ts source, not a minified chunk. (Wired by: source maps.)

  5. Check the release. Confirm the issue is tagged with the deploy’s release (the commit SHA). (Wired by: releases + VERCEL_GIT_COMMIT_SHA.)

  6. Check the context. Confirm the user and org are attached and filterable. (Wired by: setUser + tags.)

  7. Clean up. Remove the deliberate throw (and delete the wizard’s example route).

The point of wiring all of this was never the wiring. It was the workflow the wiring unlocks when a real event shows up at 2am. Rehearse that order now. Once an issue exists, you reconstruct an incident in a fixed sequence; drag the steps into it.

Order the on-call reconstruction workflow you run once a Sentry event exists — widen first, then narrow. Drag the items into the correct order, then press Check.

Open the grouped issue and read the occurrence count — how widespread is it?
Read the symbolicated stack trace — which line in which file threw?
Check the release and author — which deploy introduced it?
Read the breadcrumb trail — what happened in the seconds before?
Filter by the user / org tag — is it one customer or everyone?

That last step, narrowing to a user or org, is where this lesson hands off to the next one. In a couple of lessons, the move after “filter by org” becomes “copy the requestId off this event and jump straight to the matching log lines,” because the error surface and the log surface will share that ID. You’ve built the first surface. Next we build the second.

The exact file set the wizard writes shifts as the SDK evolves, so when a config detail here ages, the canonical reference is Sentry’s own manual-setup guide. And the Sentry–Vercel integration is the recommended way to wire releases and tokens, so its doc is the one to follow.