Lesson 27 / 27
Error Tracking
The points where client errors are caught, making the stack trace readable with a source map, grouping by generating a fingerprint, measuring a group's impact, and decisions about noise and privacy.
Contents
Metrics say how fast the application is; they do not say whether it works. If an exception is thrown while the measurement table is rendering, that session’s duration metrics keep looking fine. The user looks at a blank screen and, most of the time, tells no one.
This lesson builds the mechanism that makes those silent failures visible: catching the error, sending it, and reading thousands of events not one by one but in groups that trace back to the same origin.
Points of Capture
Client errors do not come from a single place. There are four separate sources, and each has its own boundary.
Uncaught exceptions are reported through the document-level error event; the event object
is an ErrorEvent instance and carries the message, the source, the location, and the error
object. Unhandled rejected promises come through the unhandledrejection event — this is the
browser’s counterpart to the unhandled rejection concept defined in the Asynchronous
JavaScript and the Runtime course. Resource loading errors do not bubble; they can be seen
only by listening during the capture phase. The fourth source is errors the application
reports itself: states that were caught but could not be recovered from.
// error-collector.js — collecting client errors const endpoint = '/errors'; const common = { version: '2.4.1', path: location.pathname }; function report(type, message, stackTrace) { navigator.sendBeacon(endpoint, JSON.stringify({ ...common, type, message, stackTrace })); } addEventListener('error', (event) => { if (event.target !== window) { // resource loading error report('resource', `${event.target.tagName} failed to load`, event.target.src ?? ''); return; } report('exception', event.message, event.error?.stack ?? ''); }, true); addEventListener('unhandledrejection', (event) => { const reason = event.reason; report('rejection', String(reason?.message ?? reason), reason?.stack ?? ''); });
One boundary must be known from the start. An error thrown from a script loaded from a different origin is given by the browser without detail: the message only says it is a script error, the stack trace is empty. The reason is cross-origin isolation — the rules from the Cross-Origin Resource Sharing lesson apply here too. Opening up the detail requires the script to be requested cross-origin and the server to send the header that allows it.
The second boundary is at the component level. The error boundary in the Component-Based Interface Development course catches an error thrown during rendering and shows a fallback interface; these errors never reach the document-level listener and must be reported separately.
Making the Stack Trace Readable
The code in the production build is minified: function names have shrunk to single letters, lines have been merged. A stack trace like this cannot be read. The fix is the source map defined in the Modules, Tooling, and the Ecosystem course: a table that maps a minified location back to the original file, line, and name.
The mapping is done not on the client but on the side that collects errors. The source map is not shipped together with the published assets — it makes the original source readable. It is produced separately during the build and uploaded only to the collection side.
Grouping
A single defect produces thousands of events. An error on the same line fires once for every user who opens the list; for this to be readable, events that trace back to the same origin need to be gathered into a single group. The key for this is called a fingerprint.
The fingerprint is produced from the stable parts of the event, and the unstable parts are left out. A file name carrying a build digest, line and column numbers, query strings, and identity and numeric values in the message change with every release or every event; if these enter the key, the same defect scatters across dozens of groups.
// fingerprint.mjs — produces a grouping key from a stack trace const FRAME = /^\s*at\s+(?:(.+?)\s+\()?([^()]+?)\)?$/; export function frames(stackTrace) { return stackTrace .split('\n') .slice(1) .map((line) => line.match(FRAME)) .filter(Boolean) .map((m) => ({ func: m[1] ?? '<anonymous>', source: m[2] })); } export function normalizeSource(source) { if (source === '<anonymous>' || source.startsWith('node:')) return source; return source .replace(/^[a-z]+:\/\/[^/]+/, '') // origin .replace(/[?#][^:]*$/, '') // query and fragment .replace(/:\d+:\d+$/, '') // line and column .replace(/\.[0-9a-f]{6,}\.(m?js)$/, '.$1'); // build digest } export function normalizeMessage(message) { return message .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>') .replace(/\b\d+\b/g, '<number>'); } function digest(text) { let h = 0x811c9dc5; for (const ch of text) { h ^= ch.codePointAt(0); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); } export function fingerprint(event, { depth = 3 } = {}) { const [firstLine] = event.stackTrace.split('\n'); const [type, ...rest] = firstLine.split(': '); const key = [ type, normalizeMessage(rest.join(': ')), ...frames(event.stackTrace) .slice(0, depth) .map((f) => `${f.func}@${normalizeSource(f.source)}`), ].join('|'); return { key, digest: digest(key) }; } export function group(events) { const groups = new Map(); for (const event of events) { const { key, digest: id } = fingerprint(event); const bucket = groups.get(id) ?? { id, key, count: 0, sessions: new Set(), versions: new Set() }; bucket.count += 1; bucket.sessions.add(event.session); bucket.versions.add(event.version); groups.set(id, bucket); } return [...groups.values()].sort((a, b) => b.count - a.count); }
The following six events contain examples of the same defect coming from different releases and different sessions.
// report.mjs — grouping six error events import { group } from './fingerprint.mjs'; const EVENTS = [ { session: 's-1', version: '2.4.0', stackTrace: `TypeError: Cannot read properties of undefined (reading 'value') at drawRow (https://north-slope.example/assets/table.4f2a9c.js:12:31) at Array.map (<anonymous>) at MeasurementTable (https://north-slope.example/assets/table.4f2a9c.js:40:22)` }, { session: 's-2', version: '2.4.1', stackTrace: `TypeError: Cannot read properties of undefined (reading 'value') at drawRow (https://north-slope.example/assets/table.9b1d70.js:12:47) at Array.map (<anonymous>) at MeasurementTable (https://north-slope.example/assets/table.9b1d70.js:41:22)` }, { session: 's-3', version: '2.4.1', stackTrace: `TypeError: Cannot read properties of undefined (reading 'value') at drawRow (https://north-slope.example/assets/table.9b1d70.js:12:47?v=3) at Array.map (<anonymous>) at MeasurementTable (https://north-slope.example/assets/table.9b1d70.js:41:22)` }, { session: 's-2', version: '2.4.1', stackTrace: `Error: measurement 84213 could not be saved (400) at submit (https://north-slope.example/assets/form.1c8e55.js:88:11)` }, { session: 's-4', version: '2.4.1', stackTrace: `Error: measurement 91007 could not be saved (400) at submit (https://north-slope.example/assets/form.1c8e55.js:88:11)` }, { session: 's-5', version: '2.4.1', stackTrace: `TypeError: contract.submit is not a function at save (https://north-slope.example/assets/form.1c8e55.js:120:8)` }, ]; for (const g of group(EVENTS)) { console.log(`${g.id} events=${g.count} sessions=${g.sessions.size} versions=${[...g.versions].join(',')}`); console.log(` ${g.key}`); }
node report.mjs
f76aa486 events=3 sessions=3 versions=2.4.0,2.4.1
TypeError|Cannot read properties of undefined (reading 'value')|drawRow@/assets/table.js|Array.map@<anonymous>|MeasurementTable@/assets/table.js
9581ca18 events=2 sessions=2 versions=2.4.1
Error|measurement <number> could not be saved (<number>)|submit@/assets/form.js
d3fd0a38 events=1 sessions=1 versions=2.4.1
TypeError|contract.submit is not a function|save@/assets/form.js
Six events came down to three groups. The first group’s three examples carried different build digests, different line and column numbers, and one of them a query string; normalization eliminated these. The second group’s two examples contained different measurement numbers; because the numbers were converted to a placeholder, they appeared as a single defect.
Grouping can be miscalibrated in two directions. Over-grouping collects independent defects into a single row: discarding the message entirely and looking only at the error type leads to this. Under-grouping, on the other hand, splits a single defect into separate groups per release, and none of their impact becomes visible. The measure of calibration is usefulness: a group is drawn correctly if it can be closed with a single fix.
Reading a Group
The numbers a group carries determine prioritization. Event count says how often the defect fires, affected session count says how many people it touches. The two must be kept separate: an error that fires thousands of times in a loop within a single session tops the list by event count but concerns one user. Sorting is done by session count.
Version distribution is the second piece of information. If a group appears only in the latest version, the defect arrived with that release and rollback is an option. First-seen and last-seen times answer the same question on the time axis.
Noise is a separate matter. Errors from browser extensions and external scripts are not in the application’s code; they can be filtered out if the stack trace’s first frame does not belong to the application’s domain. Errors produced by requests canceled while the user is leaving the page are not real defects either.
Volume must be controlled. An error thrown inside a loop can send thousands of events from a single session; a per-session rate limit and sampling cut this off. When sampling is applied, the count of events not sent is also transmitted, otherwise the impact measure is thrown off.
Privacy is the final decision. An error message can contain text the user typed, and the address can carry an identifier. The collector masks known identifier formats and free text before sending. Personal data is data that is hard to delete once it has been collected.
Summary
- Client errors come from four points: uncaught exceptions, unhandled promise rejections, resource loading errors, and errors the application reports itself.
- An error from a script loaded from a different origin is given without detail; detail requires a cross-origin request and a permission header. The stack trace is resolved on the collection side with a source map.
- The fingerprint is produced from the error type, the normalized message, and the first few frames; build digest, line and column number, query string, and numbers in the message are left out.
- Six events came down to three groups: examples from different releases and different measurement numbers appeared as a single defect.
- Prioritization is done by affected session count, not event count; noise is filtered, volume is limited, and personal data is masked before sending.
Course Wrap-Up
The Frontend Quality course tied four topics to the same principle: there is no quality claim without measurement. The performance topic defined user-centric metrics and tied them to a budget. The accessibility topic addressed criteria at the standard’s level and showed where automated auditing ends and manual verification begins. The security topic turned client-side risks into layers of defense. This last topic answered the question common to all three: when what was built breaks, what reports it? The answer ran in two directions — tests that run before a change and monitoring that runs after a release. There is a loop between the two: a defect seen in the field turns into a test that will never let it through again; a case the test missed comes back in the field as an error group.
With this course, the Frontend Development curriculum also comes to a close. The path started from the document — building structure with HTML and how the browser processes that structure. Then it moved to styling: cascading, the box model, and visual properties. Layout systems and responsive design addressed adapting that structure to different screen and input conditions. The browser and the web platform turned the page from a document into an application with behavior. Component-based development split that behavior into reusable pieces; application architecture brought those pieces together into a whole with routing, state, and data access. Rendering strategies asked whether the same application would be produced on the server or the client, when, and at what cost. And this last course showed how to know that all these decisions stay correct: from document to style, from layout to behavior, from component to architecture, from rendering to quality.
One question was deliberately left out across the curriculum. How a button would work, what role it would carry, which criteria it would meet in which states was covered; why that button sits at that size, that color, and in that place was not. The rationale behind a spacing scale, a typographic scale, color roles, and state design is a field of its own. The Interface Design and Design Systems curriculum addresses this question: it ties visual and interaction decisions to justifiable principles and turns those decisions into a system that scales. Its first course, Fundamentals of Interface Design, is well suited to be read alongside this curriculum — the decisions about the appearance of the components built here find their rationale there.
To keep your progress and take notes, Log in
My notes
Log in to take notes.