Design a frontend logging and error SDK
The browser SDK of an error tracker like Sentry or Datadog: it catches every kind of failure on the page, turns minified stack traces from different browsers into one readable issue per bug, records what happened before the crash, strips personal data, and keeps the noise down without ever breaking the page it watches.
The question
Our web app has 2 million daily users. When something breaks in their browser, we find out from angry support tickets days later, with a screenshot and no stack trace.
Design the browser side of an error and logging tool: the SDK that catches errors and logs on the page and sends them to our backend, so an engineer sees one clear issue per bug, with a readable stack trace, what the user did just before, and which release broke it.
Real-life examples: Sentry's browser SDK, with breadcrumbs, release tracking and session replay, Datadog RUM and Browser Logs, Bugsnag and Rollbar browser notifiers, LogRocket, which pairs errors with a recording of the session, An in-house error tracker that a large company builds on its own pipeline
Requirements exploration
Every frontend team ships bugs that only appear on real devices: a Safari version the team never tested, a browser extension that rewrites the page, a slow phone where a race finally loses. Without an SDK in the page, those bugs are invisible. The server logs show a healthy 200 for the HTML, and the user sees a blank screen.
So the job sounds simple: catch errors, send them home. But the error a browser hands you is often useless on its own. It may say only Script error., or point to line 1, column 48,213 of a minified file, or be one of 40,000 copies of the same bug. Most of the design is about turning raw browser failures into something an engineer can act on, while staying small, private, and harmless.
A few words first: an event here is one captured error or log line, with its context. An issue is a group of events that the backend decides are the same bug. A release is one deployed build of the app, named by a version or a commit hash. Symbolication means turning a minified location like app.4f9c.js:1:48213 back into checkout/Cart.tsx:88 using a source map.
Questions to ask first
- Is this SDK for our own app, or a product other companies install? Our own app means one framework, one build pipeline, and we control the source maps. A product like Sentry means any framework, any bundler, and much stricter rules about bundle size and never breaking the host.
- Errors only, or logs too? Errors are rare and precious. Logs (
info,warn,debug) can be 100 times more frequent and need levels, sampling, and a cost story. - How much traffic? 2 million daily users with 6 page views each is 12 million page views. If 2% of views hit at least one error, that's 240,000 error events a day before dedupe. One bad release can multiply that by 50 in an hour.
- Do we build our own pipeline? We should assume the analytics-style transport (batching,
sendBeacon, offline queue) already exists or is shared. That's covered in the analytics SDK guide, so here we focus on what's special about errors. - Which privacy rules apply? Error payloads are full of accidental personal data: emails in URLs, tokens in request bodies, names in messages. Under GDPR, that can count as personal data even inside a log.
- Do we want session replay? It's the most useful and most expensive feature, with big privacy and bundle-size costs. We should know early whether it's in scope.
Functional requirements
- Capture uncaught errors, unhandled promise rejections, failed resource loads (images, scripts, CSS), errors caught by React error boundaries, and failed network requests.
- A public API:
captureException(error),captureMessage(text, level),log.info / warn / error(message, fields),setUser,setTag,addBreadcrumb. - Attach a trail of breadcrumbs: the last navigations, clicks, console lines, and requests before the error.
- Attach context: release, environment, route, a hashed user id, session id, browser and OS, active feature flags.
- Produce stack traces that the server can symbolicate with private source maps and group into issues.
- Scrub personal data before anything leaves the browser.
- Join frontend events to backend traces with a correlation id sent on our API requests.
Non-functional requirements
| Requirement | Target | Why |
|---|---|---|
| Bundle size of the core | under 15 KB gzipped, replay lazy-loaded | It ships on every page, before the app. Every KB is paid by every user. |
| Work on the main thread | under 1 ms per captured error, no long tasks | Error storms happen during the worst moments, often in a loop. |
| Never throws | every SDK entry point wrapped | A crash in the error tracker is the worst possible bug: it hides the real one or causes a new one. |
| Events per page | capped, for example 100 errors and 1,000 logs | A render loop that throws 60 times a second must not send 60 requests a second. |
| Privacy | scrubbing on by default, allowlists | Data that never leaves the browser can't leak from our backend. |
| Delivery | best effort, keep fatal errors | A crash right before the tab closes is the one we most want. |
Out of scope, and why
- The transport layer in detail. Batching, the loader stub queue,
sendBeaconandfetchwithkeepalive, offline queues, and consent are the same as in the analytics SDK guide. We reuse them and only note where errors need different rules. - Performance metrics like LCP and INP. That is real user monitoring, a sibling product. We'll touch it only where it shares context with errors.
- The backend storage engine. We describe what the dashboard needs from the client, not how to build a columnar store.
Check your understanding · Exercise 1
Which answers to the scoping questions would change the client design the most? Select all that apply.
What makes this hard
An analytics SDK sends facts the app chose to send. An error SDK sends facts nobody chose: things went wrong in places we didn't expect. That flips several assumptions, and each one drives a later chapter.
- Failures come through many doors. A thrown error, a rejected promise, a broken image, a crash inside a React render, a
500from the API, aconsole.errorfrom a library. Each reaches us through a different browser hook, and some reach none by default. - The raw data is bad. The browser gives a stack trace as a plain string, in a different format per engine, pointing into minified code. For scripts from another origin it may give nothing but
Script error. - Volume is bursty, not steady. Analytics traffic follows users. Error traffic follows bugs: zero for a week, then 50,000 events in ten minutes after a bad deploy, often the same error repeated in a loop.
- The payload is dangerous. Error messages, URLs, and request bodies were never designed to be logged. They contain emails, tokens, and card numbers by accident.
- The SDK runs during the crash. It must work when the app is in its worst state: memory leaking, a loop throwing, the network down. And it must not make that state worse.
- The value is in the grouping. 40,000 events are useless. One issue that says "new in release 4.12, 3,100 users,
Cart.tsxline 88, after clicking Pay" is what an engineer can fix.
release and flags.So the SDK has four jobs, and the chapters follow them: catch every kind of failure. Enrich it with stack, breadcrumbs, and context so the server can symbolicate and group it. Protect the user and the host: scrub data, cap volume, never throw. Send it, reusing the transport we already have.
Think first
Our analytics SDK already batches events and sends them reliably. Why not just call analytics.track("error", { message, stack }) from a global handler and be done?
It would send something, but almost nothing useful. The stack would be a minified string in three formats, so nobody could read it or group it. There would be no breadcrumbs, so nobody would know how the user got there.
Volume would be wrong too. Analytics sampling drops 90% of events at random, which might drop the only copy of a rare crash, while an error loop would flood the queue with 10,000 copies of one bug.
And the privacy rules differ: many teams treat error monitoring as needed to run the service rather than as marketing, but that only holds if the payload is scrubbed hard. The transport can be shared. The capture, enrichment, and noise rules can't.
Capturing errors: every door a failure can come through
The naive SDK sets window.onerror = report and stops. On a real app that misses most failures. Picture a checkout page: a product image returns 404, a fetch to /api/pay returns 503, an await deep in the cart code rejects with nobody catching it, and a React component throws while rendering. window.onerror sees at most one of those four: only the render crash is a thrown script error, and an error boundary may catch it first.
The browser hooks, one by one
| Failure | What catches it | What you get |
|---|---|---|
throw in a script or a timer callback | window.onerror (called with five arguments) or addEventListener("error") (called with one ErrorEvent) | The same data either way: message, the script URL (source for onerror, filename on the event), lineno, colno, and error (the thrown value, with stack) |
| Image, script, or CSS that fails to load | Only a capture-phase listener: addEventListener("error", fn, true) | A plain Event whose target is the element; read src or href. No message, no status code |
| Rejected promise nobody handles | addEventListener("unhandledrejection") | A PromiseRejectionEvent whose reason can be anything: an Error, a string, undefined |
| Error during a React render | An error boundary, plus onCaughtError and onUncaughtError on createRoot in React 19 | The error and a componentStack string |
fetch rejects (offline, DNS, CORS) | Wrapping fetch | A TypeError whose message differs per browser |
fetch resolves with 500 | Wrapping fetch and checking response.status | Nothing is thrown at all; it's a normal response |
A library calls console.error | Wrapping console methods | The arguments, often an Error or a string |
Resource errors are the surprise. An image's error event does not bubble, so a listener on window in the normal (bubbling) phase never hears it. But capture runs from window down to the target before the target's own handlers, so a listener registered with true as the third argument sees every failed img, script, and link on the page. To tell them apart from script errors, check event instanceof ErrorEvent: resource failures are a plain Event.
Decision How should the SDK hook into global errors?
What we pick: Use listeners as the base, because they're safe and catch resource errors, and add a small amount of wrapping on top: fetch and XMLHttpRequest (we need them for breadcrumbs and 5xx events anyway), console, and optionally timers and event listeners for richer context. Sentry's browser SDK makes the same split: global handlers plus a set of instrumentation integrations that can each be turned off. Never assign the on... properties.
function installGlobalHandlers(hub: Hub) {
const onError = safe((e: Event) => {
if (e instanceof ErrorEvent) {
hub.captureException(e.error ?? e.message, {
mechanism: "onerror",
filename: e.filename, lineno: e.lineno, colno: e.colno,
});
return;
}
const el = e.target as HTMLElement | null;
if (el && el !== (window as unknown)) {
hub.captureResourceError(el.tagName, (el as HTMLImageElement).src
?? (el as HTMLLinkElement).href);
}
});
const onRejection = safe((e: PromiseRejectionEvent) =>
hub.captureException(e.reason, { mechanism: "unhandledrejection" }));
window.addEventListener("error", onError, true); // capture: sees resources
window.addEventListener("unhandledrejection", onRejection);
return () => {
window.removeEventListener("error", onError, true);
window.removeEventListener("unhandledrejection", onRejection);
};
}
Rejections whose reason isn't an Error
Promise.reject("nope") and throw { code: 42 } are legal JavaScript. The reason then has no stack, and if we report it as is, every string-rejection in the app collapses into one useless issue.
So the SDK normalizes. If reason is an Error, use it; if it's a string, use it as the message; if it's an object, serialize a few keys safely, with a depth and length cap and a guard against cycles. Then mark the event as synthetic, so the server knows that any stack we add with new Error() points at the SDK, not at the bug.
Errors can also wrap other errors. Since ES2022, new Error("checkout failed", { cause: err }) keeps the original in error.cause. The SDK walks that chain up to a small limit and sends each link as its own exception with its own frames, so the low-level failure isn't lost behind the wrapper. Sentry's linked errors integration does this, following cause up to 5 levels by default.
React error boundaries and their limits
An error boundary is a React component that catches errors thrown while rendering the tree below it, shows a fallback, and lets the rest of the page live. It's the right place to report render crashes, because it also gives a componentStack: the chain of components that were rendering.
But boundaries only catch errors during rendering, in lifecycle methods, and in constructors. They do not catch errors in event handlers (onClick), in async code (setTimeout, an await after a fetch), or in the boundary itself. Those fall through to the global hooks.
React 19 made this easier to wire: createRoot accepts onUncaughtError (no boundary caught it), onCaughtError (a boundary did), and onRecoverableError (React recovered on its own, for example from a hydration mismatch). The SDK's React package passes its handlers there, so render errors are reported once, with the component stack, and aren't double-counted by the global listener.
502, is not an error to the browser at all: only the fetch hook sees it, as a breadcrumb. The crash arrives later through unhandledrejection, and an error boundary around the button never sees either.Network failures and HTTP 5xx
A fetch that gets a 500 does not throw: the promise resolves with response.ok === false. If the app forgets to check, the user sees a spinner forever and the SDK sees nothing. So the fetch wrapper records every request as a breadcrumb (method, URL without query, status, duration) and turns 5xx responses on our own API into events, with a fingerprint by method plus route template (POST /api/orders/:id) so 10,000 failed orders form one issue.
A fetch that rejects means the request never completed: offline, DNS failure, CORS block, or an abort. The message is browser-specific: Failed to fetch in Chrome, NetworkError when attempting to fetch resource. in Firefox, Load failed in Safari. The SDK normalizes those three into one type, NetworkError, and skips AbortError, which the app caused on purpose.
console.error wrapping is a smaller version of the same idea: call the original first so DevTools still works, then record a breadcrumb, and only create an event if the argument is an Error (many libraries log warnings through console.error, and turning each into an issue is noise).
The "Script error." problem: muted cross-origin errors
Sooner or later every error dashboard grows a top issue called Script error. with no stack, line 0, column 0, and tens of thousands of events. It's not one bug. It's every error from every script served from another origin, flattened into one line on purpose.
Browsers do this for safety. If a page could read the full error from any script it loads, an attacker's page could load a script from your bank's origin and learn things from its error messages (for example, whether a user is logged in, based on which error a JSON response produces when run as a script).
So the HTML spec says that when a script is loaded from another origin without CORS, its errors are muted: the error event gets the message Script error., an empty filename, line and column 0, and a null error object, so the real message and the stack are hidden.
Our own app hits this all the time, because the bundle usually lives on a CDN (cdn.shop.com or d1x.cloudfront.net) while the page is on www.shop.com. Different host means different origin. A micro-frontend shell that loads remotes from several teams' origins hits it once per remote (the micro-frontend shell guide covers attribution per app).
The fix: CORS for scripts
- Add
crossorigin="anonymous"to the script tag. The browser now fetches the script in CORS mode, without cookies. - Make the CDN answer with
Access-Control-Allow-Origin: https://www.shop.com(or*for a public, cookie-less asset). - If the CDN caches responses for several origins, also send
Vary: Origin, or always send*, so one origin's cached header isn't served to another.
Both halves are required, and getting only one wrong is worse than doing nothing. With the attribute but no header, the CORS check fails and the script doesn't run at all: the outage we were trying to debug becomes a real outage. That's why the change must ship header first, verified, then attribute.
Module scripts (type="module") are always fetched in CORS mode, so an app built that way already needs the header. For classic chunks loaded later, webpack's output.crossOriginLoading adds the attribute to each lazily loaded chunk, which is easy to forget.
crossorigin="anonymous" on the tag and Access-Control-Allow-Origin on the response. The attribute alone, without the header, stops the script from running at all.For third-party scripts we don't control (a chat widget, an ad tag), we usually can't get the header. Two fallbacks: wrap the callbacks we pass into their APIs in our own try/catch (a caught error is never muted, which is one reason SDKs instrument addEventListener and timers), or simply drop Script error. events whose filename is empty, since without a stack nobody can act on them. Drop them, but count them: a sudden spike still says "something broke in a third-party script after 14:00".
Check your understanding · Exercise 2
The team adds crossorigin="anonymous" to the main bundle's script tag and deploys. Within minutes the site is blank for everyone. What happened, and what is true? Select all that apply.
Check your understanding · Exercise 3
A user clicks Save. The onClick handler awaits fetch("/api/save"), which returns 502, then calls data.items.map(...) on the response body null. The component is inside an error boundary. Which statements are true? Select all that apply.
Stack traces and grouping: one bug, one issue
Say release 4.12 ships a bug: Cart.tsx calls data.items.map when items is missing. In an hour it fires 40,000 times across Chrome, Firefox, and Safari. The engineer should see one issue with a count of 40,000. The naive grouping, "same message, same issue", produces at least three, because each engine words the same TypeError differently.
| Engine (browser) | Message for the same bug |
|---|---|
| V8 (Chrome, Edge) | Cannot read properties of undefined (reading 'map') |
| SpiderMonkey (Firefox) | can't access property "map", data.items is undefined |
| JavaScriptCore (Safari) | undefined is not an object (evaluating 'data.items.map') |
Messages also carry data: Order 18842 not found and Order 18843 not found are the same bug with different ids. So the message is a bad grouping key. The stack is a much better one, but only after two steps: parsing, and symbolication.
Parsing three stack formats
error.stack is not in the ECMAScript standard yet. A TC39 proposal to standardize the property itself is at Stage 3, but it deliberately leaves the string's format to each engine. V8 writes at addToCart (https://cdn.shop.com/app.4f9c.js:1:48213). SpiderMonkey and JavaScriptCore write addToCart@https://cdn.shop.com/app.4f9c.js:1:48213.
Anonymous functions, eval, async frames (V8 prefixes async ), and native code each add variants. V8 also keeps only 10 frames by default (Error.stackTraceLimit). An SDK can raise that limit for its own captures, to 50 for example, at a small cost per error.
in_app by its file. Columns can differ by engine for the same call; source maps on the server bring them back to one line of source.The SDK parses the string into a list of frames: { function, filename, lineno, colno }, oldest call first or last by a fixed convention. Each frame is also tagged in_app: true or false: frames from our own bundle are in-app; frames from third-party scripts, browser extensions (chrome-extension://, moz-extension://, safari-web-extension://), and the SDK's own code are not.
Parsing on the client costs a few regular expressions per frame, well under a millisecond, and means the server gets a structured format no matter which engine produced it. Keep the raw string too, capped at a few KB, for formats the parser didn't recognize.
Decision How should events be grouped into issues?
What we pick: Group on the server by the top in-app symbolicated frames, with message normalization as the fallback and an app-provided fingerprint as an override. The client's part is to send a clean, parsed, in_app-tagged frame list plus the error type, and to let developers set a fingerprint. Grouping must happen on the server because only the server has the source maps. Sentry's grouping follows the same order: in-app stack frames first, then exception type and value when there's no usable stack, with a fingerprint as the override.
Normalization rules for the fallback are simple and worth naming: replace numbers, UUIDs, hex hashes, emails, and quoted strings in the message with placeholders (Order {n} not found), and map known per-engine messages to one canonical text. That second table is small but valuable: a few dozen entries cover the most common TypeError and ReferenceError shapes.
Check your understanding · Exercise 4
Release 4.13 changes nothing in Cart.tsx, but a new dependency shifts every column in the minified bundle. With grouping by top in-app symbolicated frames (file plus function), what happens to the items.map bug? Select all that apply.
Source maps: readable stacks without publishing your source
A minified frame like app.4f9c.js:1:48213 means nothing to a person. A source map is a JSON file the bundler writes next to each output file. It maps every position in the minified code back to a file, line, column, and original name in the source, and it can embed the original source text itself (sourcesContent). With it, the server can show src/checkout/Cart.tsx:88 in CartList and even the five lines around it.
The format is standardized by Ecma as ECMA-426, and maps still carry "version": 3. sources lists the original files, names lists original identifiers, and mappings is one long string of Base64 VLQ (variable-length quantity) segments. Each segment links a generated column to a source file, line, column, and optionally a name, all stored as offsets from the previous segment to keep the file small.
The naive setup ships the maps publicly: the bundle ends with //# sourceMappingURL=app.4f9c.js.map, the map sits on the CDN, and the error server downloads it when needed. It works, and it hands your full, commented source code to anyone who opens DevTools. For many companies that's an IP and security problem: internal endpoints, feature names before launch, and comments that say what's broken.
Decision How does the error server get the source maps?
What we pick: Private upload, always. Prefer debug IDs if the toolchain supports them (Sentry's SDKs do from version 7.47), and still tag events with a release, because releases drive regression tracking even when they no longer drive symbolication. For an in-house tool, the content hash already in the file name (app.4f9c.js) is a fine key: it's unique per content, which is the property a debug ID provides.
.map file, so source stays private, and the server can still map every frame because it was given the maps directly.What the server does with them
Symbolication runs in the ingestion pipeline, before grouping. For each frame: find the map (by debug ID, or release plus URL), binary-search the decoded mappings for the generated line and column, read the original file, line, column, and name, and attach a few lines of sourcesContent around it. A large app's map can run to many megabytes, so the server parses each one once and caches the decoded form in memory, keyed by ID.
Frames from files with no map stay minified and are marked so, which is itself a useful signal ("uploads failed for 4.13").
- Make the upload block the deploy. If upload fails, the release is unreadable. A CI step that fails the pipeline is cheaper than a week of minified stacks.
- Keep old maps. Users keep tabs open for days. Errors from release 4.10 still arrive after 4.13 ships, so keep maps for at least as long as you keep events, often 90 days.
- Set the release at build time, not by hand. Inject the git commit hash into the bundle (for example through a
defineconstant), and use the same value for the upload. One source of truth means no mismatch. - Don't forget lazy chunks and workers. Every output file needs its map uploaded, including code-split chunks and web worker bundles.
Check your understanding · Exercise 5
After a CDN migration, stack traces for new events are minified again, while old events still look fine. Maps are uploaded on every build, matched by release and file URL. What are likely causes? Select all that apply.
Scrubbing personal data before it leaves the browser
Look at what a single real error event can contain without anyone meaning it to. The URL: /reset?email=ana@mail.com&token=8f2c.... The message: Invalid card 4111 1111 1111 1111. A breadcrumb: GET /api/users/ana@mail.com, and a request body attached for debugging holds a full address.
Under GDPR, data that can identify a person, like an email address, counts as personal data even inside a log. Once it's in the error store it's copied into backups, search indexes, Slack alerts, and the laptops of everyone who opened the issue. Deleting it on request becomes nearly impossible.
So the rule is: scrub on the client, before the event is queued. Data that never leaves the browser can't leak from any of those places. The server scrubs again as a second line, because SDK versions in the wild are old and app code adds new fields, but the client pass is the one that matters.
Decision How should the SDK decide what to remove?
What we pick: Allowlist the structure, denylist-mask the free text. Query strings, headers, and bodies are dropped unless named. Messages, stack strings, and breadcrumb messages go through pattern masking, because we can't drop them and still debug. And give the app a final beforeSend(event) hook to scrub domain-specific fields or drop the event entirely.
| Pattern | Example | Replaced with |
|---|---|---|
ana@mail.com | [email] | |
| Card-like number: 13 to 19 digits with spaces or dashes, passing the Luhn check | 4111 1111 1111 1111 | [card] |
JWT: three base64url parts separated by dots, starting with eyJ | eyJhbGciOi... | [jwt] |
| Bearer or API token in text | Bearer sk_live_... | Bearer [token] |
| Query values in any URL | ?q=flu+symptoms&page=2 | ?q=[filtered]&page=2 (only allowlisted keys like page kept) |
| Long hex or base64 strings over 32 chars | session keys, reset tokens | [secret] |
The Luhn check on card-like numbers matters. Without it, every 16-digit order id becomes [card] and grouping breaks; with it, about 1 in 10 random digit strings still pass, which is an acceptable false-positive rate for a privacy filter. Scrubbing runs on every string field of the event once, just before queueing, with a cap on total string length so a 2 MB error message can't block the main thread with regex work.
beforeSend hook runs last.What GDPR changes for the design
- Legal basis. Many teams treat error monitoring as needed to run a secure, working service (a legitimate interest), unlike marketing analytics, which usually needs consent. That argument only holds if the data is minimized. Session replay and full request bodies are much harder to justify without consent. This is a call for the company's privacy team; the SDK's job is to make the minimal mode the default and the richer modes opt-in.
- Minimization and retention. Send only what's needed to debug, and keep it only as long as needed: 30 to 90 days is common for error events.
- Deletion requests. If events carry a stable user id, the backend can find and delete one user's events. That's another reason to use an opaque id rather than an email.
- Region. EU customers often require that events are stored in the EU. That's a DSN or endpoint choice the SDK exposes, not something it can solve alone.
Check your understanding · Exercise 7
Which scrubbing decisions are right? Select all that apply.
Noise control: dedupe, rate limits, filters, and sampling
Here's a common bad day. A component throws inside a requestAnimationFrame loop. It throws 60 times a second, on 20,000 open tabs. That's 1.2 million events a second aimed at our backend from one bug, each one the same.
The error vendor bills per event, the ingestion queue backs up, and real new issues from other users wait behind the flood. Noise control is how the client keeps that to a trickle without hiding anything new.
Every captured event walks through the same gates, in order, and can be dropped at each one. The order is chosen so the cheapest checks run first and nothing expensive (scrubbing, serializing) is spent on an event that will be dropped.
Ignore rules: errors that aren't ours
- Browser extensions. Password managers, ad blockers, and translators inject scripts into the page. Their errors show frames from
chrome-extension://,moz-extension://, orsafari-web-extension://. If every frame is from an extension, drop it. - Known benign errors.
ResizeObserver loop completed with undelivered notifications.(older Chrome:ResizeObserver loop limit exceeded) means a resize callback changed layout and the browser deferred some notifications to the next frame. It's almost never a user-visible bug. Script error.with no file, as discussed: drop, but count.- Errors from old releases once a release is retired, and from bots (headless browsers, crawlers) identified by user agent.
- Aborted requests:
AbortErrorfrom the app's ownAbortController.
Ship these rules with defaults, let the app add its own (ignoreErrors: [/^Non-Error promise rejection/], denyUrls: [/googletagmanager\.com/]), and ideally fetch an updated list from the server, so a new benign error from a Chrome update can be silenced without a deploy.
Dedupe and per-issue rate limits
The client can compute a local fingerprint cheaply: error type plus the top three raw frames (file, line, column) plus the normalized message. It's not the server's grouping key (that needs source maps), but within one page load and one release it identifies "the same error again" well enough.
- Consecutive dedupe. If the new event's local fingerprint equals the previous one, drop it and increment a counter on the previous one. This alone collapses a render loop.
- Per-fingerprint budget. Keep a map of fingerprint to count for the page. Send the first 5 of each, then only a summary ("this error repeated 1,840 more times") on page hide.
- Global budget. A token bucket over all errors: for example 10 events per minute with a burst of 30, and a hard cap of 100 per page load. When the bucket is empty, events are counted, not sent.
- Server signal. If the collector answers
429withRetry-After, the transport stops sending for that long. This is where the client respects the backend's own quota. Sentry's servers also send anX-Sentry-Rate-Limitsheader with a separate limit per data category, such as errors or replays.
Sampling without losing new issues
Analytics can sample uniformly: keep 10% of sessions and multiply counts by 10. Errors can't, because the rarest events are the most valuable. A crash that hits 3 users a day would vanish with 90% sampling. Sentry's sampleRate for errors defaults to 1.0, keeping every one, and the sampling here is biased:
- Keep 100% of
fatalanderrorlevel events after dedupe and rate limits, which already removed the floods. - Sample
warningand logs by level (for example 10% ofwarn, 1% ofinfo), decided once per session so a sampled session is complete, not full of holes. - Always keep the first event of any fingerprint the client hasn't seen this session, and, if the server sends a list of known noisy fingerprints at startup, sample only those down.
- Send the sample rate with each event (
sample_rate: 0.1) so the dashboard can scale counts back up honestly.
Check your understanding · Exercise 8
A bug throws on every animation frame for 20,000 users. Which client rules keep the backend safe without hiding the bug? Select all that apply.
Structured logs and joining frontend to backend traces
Errors answer "what broke". Logs answer "what was the app doing": checkout started, payment provider timed out, retrying, cart migrated from v1 format. The naive frontend log is console.log("payment failed for " + orderId). It's a string, so nobody can filter by order, count by provider, or alert on it, and it only exists in a browser console nobody is watching.
A structured log is a JSON object with fixed fields, so the backend can index and query it. The SDK's log API takes a message template and fields separately, and adds context automatically. The frontend-logging lesson covers schemas, levels, and batching as concepts; here we only add what an error SDK changes.
log.warn("payment retry", { provider: "stripe", attempt: 2, orderId });
// queued as:
{
"ts": "2026-10-21T11:42:07.118Z",
"level": "warn",
"message": "payment retry",
"fields": { "provider": "stripe", "attempt": 2, "orderId": "ord_18842" },
"release": "4.12.0+9f3e1c2",
"env": "production",
"session_id": "s_7b1e",
"route": "/checkout/:step",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
- Levels are
debug,info,warn,error,fatal. Each level has its own sample rate and on/off switch, and production defaults toinfoand up. - A constant message (
payment retry, notpayment retry for ord_18842) groups and counts well, and is far less likely to contain personal data. - Fields go through the same scrubber as errors, with an allowlist of field names for anything that looks like user input.
- Remote level control: the SDK can fetch a config that turns on
debugfor one session id or one user, so support can debug a single customer without flooding everyone. - Logs as breadcrumbs: every log line is also pushed to the breadcrumb ring, so an error carries the logs that led to it even when the logs themselves were sampled out.
Correlation ids and W3C Trace Context
The frontend error says POST /api/orders returned 500. The backend has a log line about a database timeout at the same minute. Matching by timestamp to tell whether they're the same request fails at 1,000 requests per second. What joins them is a correlation id that the browser creates and sends with the request, and that every backend service logs.
The standard for that id is W3C Trace Context: a traceparent header shaped 00- + 32 hex chars of trace id + - + 16 hex chars of parent span id + - + 2 hex chars of flags, for example 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. The flags' lowest bit says whether the trace is sampled. OpenTelemetry and most tracing backends, Datadog included, read it, so one header joins the browser's click to the database query.
Two caveats. Sentry's SDKs use their own sentry-trace header by default, and its JavaScript SDK sends traceparent too only when propagateTraceparent is on (from version 10.10). OpenTelemetry's own browser instrumentation exists, but its docs still call client-side support experimental, so check what your backend tracer expects before you pick a header.
4bf92f35..., now sits on the frontend event, the API's log line, and the orders service's span. The header goes only to our own API origin, listed in the propagation allowlist.The fetch wrapper already sees every request, so it adds the header. Two rules keep this safe:
- Only to our own APIs.
traceparentis not a CORS-safelisted header, so adding it to a cross-origin request triggers a preflightOPTIONSrequest, and if the server doesn't list it inAccess-Control-Allow-Headers, the real request fails. Adding it to a third-party API would break that API call. So propagation targets are an explicit allowlist (Sentry calls this optiontracePropagationTargets). - Never trust it for anything but correlation. The backend must treat the incoming trace id as a hint, not as identity or authorization.
With the header in place, the error event stores trace_id, and the dashboard can link the frontend issue straight to the backend trace that failed. The same id also appears in the backend's error response body, which lets a support agent paste it from a screenshot.
Check your understanding · Exercise 9
Which statements about frontend logs and trace propagation are true? Select all that apply.
The SDK's own safety: never throw, never loop, stay small
An error SDK is code that runs exactly when things are going wrong. If it throws, it either hides the original error or creates a new one, and it does so on every page of the app. Three failure modes deserve design, not just care.
1. Never throw out of the SDK
Every public method and every hook the SDK installs runs inside a try/catch that swallows the SDK's own failures. Serializing an unknown reason can throw (a cyclic object, a getter that throws, a Proxy), so serialization uses a guarded walker with a depth limit of about 5 and a size limit per field. Wrapped globals must behave exactly like the originals: same return value, same this, rethrow the app's error after reporting it, and keep a map from the original listener to the wrapped one so removeEventListener still works.
let internalErrors = 0;
let inSdk = false;
export function safe<A extends unknown[], R>(fn: (...a: A) => R) {
return (...args: A): R | undefined => {
if (inSdk) return undefined; // re-entry: an error raised by our own code
inSdk = true;
try {
return fn(...args);
} catch {
internalErrors++; // sent as a counter in the next healthy batch
return undefined;
} finally {
inSdk = false;
}
};
}
2. Never feed on yourself
Loops are the classic SDK outage. The send fails, the fetch wrapper sees a failed request and records an error, which gets queued and sent, which fails. Or the SDK logs a warning with console.error, which the console wrapper captures as an error. The defenses:
- The transport calls the original, unwrapped
fetchsaved at startup, so its own requests never appear as breadcrumbs or events. - The SDK's own diagnostics go to the original
console, and only whendebug: true. - The re-entry flag above: anything captured while the SDK is already running is dropped.
- Transport failures back off exponentially with jitter, and after a few failures in a row the transport stops trying for the rest of the page (a circuit breaker), keeping a small capped queue for the next page load.
3. Stay small, load the heavy parts later
The SDK must start before the app, or it misses errors during startup. That puts its bytes on the critical path of every page. The split that works:
| Part | When it loads | Rough size (gzipped) |
|---|---|---|
| Global handlers, scope, breadcrumb ring, stack parser, scrubber, dedupe | In the main bundle or a tiny inline snippet, first | around 10 to 15 KB |
| Transport (shared with analytics if present) | Same bundle | 2 to 4 KB |
| React integration (boundary, root hooks) | With the app's React code | 1 to 2 KB |
| Session replay recorder | Lazy: import() after load and idle, only for sampled sessions | tens of KB, more than the core |
| Log viewer overlay, user feedback dialog | Lazy, on demand | varies |
Errors that happen before even the small core loads, such as a syntax error in the main bundle, are caught by a few lines inlined in the HTML that push to a plain array, the same stub-queue pattern the analytics SDK guide describes. The core drains it on startup.
Session replay, briefly
Session replay records the page as a stream of DOM changes (a full snapshot, then MutationObserver diffs, plus scroll, mouse, and input events) and plays it back like a video next to the error. Open-source rrweb is the base of several products. It's the most direct answer to "what did the user see", and it's expensive in every way: tens of KB of code, CPU on every DOM change, hundreds of KB to several MB of data per session, and the hardest privacy questions.
- Mask by default. Replace all text with placeholders and block images, video, and inputs unless an element is explicitly unmasked. Sentry's replay masks all text and blocks media by default; follow that.
- Record only some sessions. For example 1% of sessions in full, plus a rolling buffer of the last 60 seconds in memory for every session that's flushed only if an error happens. That second mode gives replays for most errors at a fraction of the cost.
- Respect consent. Replay is closer to analytics than to error monitoring. Gate it on consent where the law requires.
Check your understanding · Exercise 10
The collector is down. Which SDK behaviors are correct? Select all that apply.
Architecture, data model, and the dashboard it feeds
With the decisions made, the SDK falls into a few modules with narrow jobs. The boundaries follow the pipeline: things that observe the page, one place that holds context, one processor that turns raw failures into safe events, and the shared transport.
| Module | Responsibility | Why it's separate |
|---|---|---|
| Integrations | Global handlers, fetch/XHR, console, history, click wrappers, React root hooks | Each can be turned off alone when it conflicts with a host app |
| Scope | Release, env, user, tags, flags, trace id | Copied into each event at capture time |
| Breadcrumb ring | Last 100 crumbs, capped | Written constantly, read only on error |
| Processor | Normalize, parse stack, filter, dedupe, rate limit, sample, scrub, beforeSend | One ordered pipeline; cheap gates first |
| Transport | Envelope batching, page-hide flush, retry, 429 back-off, circuit breaker | Shared with analytics; the error SDK only sets priorities |
| Lazy add-ons | Replay recorder, feedback dialog | Too big for the critical path |
One transport rule is specific to errors. A fatal event (the app's root boundary fell back, or an uncaught error during startup) skips batching and is sent at once with fetch and keepalive: true, because the user is likely about to reload or leave. Everything else rides the normal batch.
Keepalive has a hard limit: all of a page's in-flight keepalive requests share 64 KiB of body, and Chromium counts sendBeacon against the same quota. So the fatal event goes without replay data and with trimmed breadcrumbs. The analytics SDK guide covers the rest of page-hide delivery: visibilitychange, sendBeacon versus keepalive, and the offline queue.
The data flow continues past the browser: the collector validates the envelope and answers fast. Ingestion workers then symbolicate with the uploaded maps, scrub again, compute the server fingerprint, and upsert the issue. Only then do alert rules run. The client's whole contribution is a clean, structured, scrubbed event; everything that needs global knowledge happens on the server.
Data model and public interface
The event is the contract between SDK and backend, so it's versioned and kept flat enough to index. Everything lives in memory on the client except the small outbound queue, which the shared transport persists in IndexedDB as the analytics guide describes.
type StackFrame = { function?: string; filename: string; lineno: number; colno: number; in_app: boolean };
type ErrorPayload = {
event_id: string; // uuid, lets the server drop duplicates on retry
ts: number;
level: "fatal" | "error" | "warning";
exception: { type: string; value: string; frames: StackFrame[]; synthetic?: boolean };
mechanism: "onerror" | "unhandledrejection" | "react" | "fetch" | "console" | "manual";
fingerprint?: string[]; // app override
release: string; env: string; debug_ids?: Record<string, string>;
user?: { id: string }; // opaque or hashed
session_id: string; trace_id?: string; route: string;
tags: Record<string, string>; // flags, plan, locale
breadcrumbs: { ts: number; category: string; message: string }[];
repeat?: number; sample_rate: number; sdk: { name: string; version: string };
};
ErrorSdk.init({
dsn: "https://ingest.example.com/p/42",
release: __RELEASE__, environment: "production",
sampleRates: { error: 1, warn: 0.1, info: 0.01, replay: 0.01, replayOnError: 1 },
tracePropagationTargets: [/^https:\/\/api\.shop\.com/],
allowUrls: [/cdn\.shop\.com/], ignoreErrors: [/^ResizeObserver loop/],
beforeSend: (event) => event, // return null to drop
});
ErrorSdk.setUser({ id: hashedUserId });
ErrorSdk.setTag("plan", "pro");
ErrorSdk.addBreadcrumb({ category: "cart", message: "synced" });
ErrorSdk.captureException(err, { fingerprint: ["payment-timeout"] });
ErrorSdk.log.warn("payment retry", { provider: "stripe", attempt: 2 });
init is idempotent.The collector endpoint is POST /api/{project}/envelope with a newline-separated body: one envelope header line, then for each item (event, logs, replay chunk) an item header line and a payload line. It answers 200 with accepted ids, 413 when an item is too large (the SDK then drops the replay and breadcrumbs and retries once), and 429 with Retry-After. A public, write-only project key in the DSN identifies the project; it can't read anything, so it's safe to ship in the bundle.
Check your understanding · Exercise 11
Why does each event carry a client-generated event_id? Select all that apply.
The dashboard the client feeds
The SDK is judged by the screen an engineer opens at 2 a.m. Working backwards from that screen explains every field we send.
release for first seen, hashed user.id for users affected, frames for the stack, breadcrumbs for the steps, tags for the breakdown, and trace_id for the link to the backend.- Issue list: one row per fingerprint, sorted by users affected, with a sparkline of the last 24 hours. Needs the fingerprint inputs,
user.id, andsample_rateto scale counts. - Regressions by release: "first seen in 4.12" and "resolved in 4.11, back in 4.12". Needs
releaseon every event, and a release record created at deploy time. - Alerts: a new issue, a spike over the baseline (for example 10 times the hourly average), or crash-free sessions dropping under 99.5% after a deploy. Needs session start events too, not only errors, so the SDK sends a tiny session ping per session.
- Issue detail: symbolicated stack with source lines, breadcrumbs, tags broken down by browser, route, and flag, a link to the backend trace, and replay when present.
Crash-free sessions is the metric teams actually gate releases on, and it's the one field naive SDKs forget: without a count of healthy sessions, you know how many errors there were but not what share of users had a bad time.
Check your understanding · Exercise 12
The team wants to block a canary rollout if release 4.13 is worse than 4.12. What must the SDK send for that? Select all that apply.
Running it in the interview
In 45 minutes you can't cover every chapter. Spend about 5 minutes on scope (own app or product, errors or logs too, replay or not), 5 on what makes it hard, then draw the architecture with the event pipeline and walk one error through it in 5. Use the next 25 minutes on two or three deep dives the interviewer cares about: usually capture, grouping with source maps, and noise or privacy. Leave 5 for follow-ups.
- Draw the browser with its failure doors on the left, the pipeline in the middle, the collector and ingestion on the right.
- Say out loud which hook catches what, and name the three gaps: resource errors need the capture phase, boundaries miss handlers and async code,
5xxnever throws. - Name
Script error.and its fix before you're asked. - Explain grouping by symbolicated in-app frames, and why source maps are uploaded privately.
- Close with safety: never throw, no feedback loops, small core, lazy replay.
Keep linking back to the transport you'd share with an analytics SDK instead of re-deriving batching and sendBeacon. Saying "same transport, different rules for priority and sampling" saves five minutes for the parts that are unique to errors.
Think first
Follow-up: your SDK is installed on a customer's site and their CSP blocks our collector domain. What happens, and what do you do?
Every send fails with a CSP violation, which the browser reports as a network failure to us. The circuit breaker must stop retries quickly, or we spam the console and waste CPU.
The fix is on the customer side: add our ingest origin to connect-src, or proxy events through their own domain (a tunnel endpoint), which also helps with ad blockers. Document both, and expose a debug mode that says clearly "sends blocked, check CSP".
Think first
Follow-up: a new Chrome version starts throwing a harmless error on 30% of page views. Alerts are firing. How does the system cope without a deploy?
Mark the issue as ignored on the server so alerts stop, and push the fingerprint or message to the server-side ignore list that SDKs fetch at startup, so clients stop sending it.
Keep counting it in a cheap aggregate, so if the "harmless" error turns out to correlate with a drop in conversions, someone can see it.
Think first
Follow-up: how would you test the SDK itself?
Unit tests for the stack parser with recorded real stacks from each engine and version, for the scrubber with a corpus of tricky strings, and for dedupe and rate limits with fake timers.
End-to-end tests in real Chrome, Firefox, and Safari (Playwright runs all three engines) that trigger each failure type and assert on the envelope. And a canary: ship new SDK versions to 1% of traffic and watch the SDK's own internal error counter.
Summary
- Errors reach the page through many doors: listen for
errorin the capture phase andunhandledrejection, hook React's root error callbacks, and wrapfetchbecause5xxnever throws. Script error.is muting of cross-origin scripts; fix it withcrossorigin="anonymous"plusAccess-Control-Allow-Origin, header first.- Parse stacks on the client into tagged frames; symbolicate and group on the server by the top in-app frames, never by raw message.
- Upload source maps privately at build time, keyed by debug ID or release plus URL, and never deploy them publicly.
- Breadcrumbs live in a capped ring buffer and record route templates, selectors, and statuses, never input values or bodies.
- Scrub on the client with allowlists for structure and pattern masking for free text, and keep a
beforeSendhook. - Control noise with ignore rules, dedupe, per-fingerprint and global budgets, and biased sampling that always keeps first occurrences.
- Propagate
traceparentonly to your own APIs to join frontend errors to backend traces. - The SDK must never throw, never feed on its own failures, and keep heavy parts like replay lazy and masked by default.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: Window error event Script and resource errors, and why resource errors need the capture phase.
- MDN: Window unhandledrejection event
- MDN: The crossorigin attribute What
anonymousanduse-credentialsdo for scripts. - React: Catching rendering errors with an error boundary What boundaries catch, and what they don't.
- React: createRoot options
onCaughtError,onUncaughtError, andonRecoverableError. - W3C Trace Context The
traceparentheader format. - Sentry docs: What are Debug IDs
- Source map specification (ECMA-426)
version,sources,names, and the Base64 VLQmappings. - Source map Debug ID proposal (TC39-TG4) The
debugIdfield and//# debugId=comment, and the proposal's current stage. - MDN: Error cause
- Sentry docs: Issue grouping Grouping by in-app frames, the type and value fallback, and fingerprints.
- MDN: RequestInit keepalive The 64 KiB body limit for requests that outlive the page.
- rrweb, the open-source session recorder
Concepts behind it
- Error trackingCatching the errors that happen in users' browsers, decoding minified stacks, grouping thousands of reports into a few issues, and alerting when a release makes things worse: how you learn the app is broken before support does.
- Logging in the browserSending small, structured records of what happened in each user's browser to your own servers, in batches that survive the tab closing, sampled and scrubbed so they stay cheap and safe: the only way to see bugs that never happen on your machine.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- CORSThe rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.