Recently I reimplemented client-side (i.e. in-browser) JavaScript error reporting for a web application that I had written years ago. This post outlines some of the things I discovered and provides a basic implementation.
Consider an Error Reporting Service
For client-side error reporting, as with many things, it is relatively simple to create a basic implementation and quite difficult to create a complete and robust one. Developers without an existing error collection system are encouraged to consider existing services, such as Bugsnag (JS client source), Rollbar (JS client source), Sentry (JS client source), and others which have (presumably) already solved the problems in this post along with many others.
Listen for both error
and unhandledrejection
events
Uncaught exceptions cause error
events
while unhandled
Promise
rejections cause
unhandledrejection
(but only on Chrome 49+, Edge, Firefox 69+, and
Bluebird at the time of this writing). Consider
listening for unhandledRejection
(with a capital R) events to catch
unhandled rejections from when.js and
yaku promise libraries, if they may be used.
Reporting unhandled exceptions from other promise implementations requires
calling the reporting function explicitly.
Carefully consider how to support IE
Websites which support any version of Internet Explorer (or Edge on a local
network) should consider how to handle errors on unsupported IE versions which
might be triggered by Compatibility
View
(from Compatibility View
List
(by user, admin, or MS), Security
Zone,
or intranet site detection settings),
EMIE,
X-UA-Compatible
(in document or HTTP header), and/or Edge Enterprise
Mode.
Be aware that the value sent in the User-Agent
header of the HTTP request
does not accurately reflect the browser version in these modes (e.g. IE 11
sends the User-Agent
for IE 7 in Compatibility View, but will still operate
in IE 11 document mode based on X-UA-Compatible: IE=Edge
in the response).
Should errors in unsupported modes be reported (to the user, administrator, or
webmaster) or ignored?
Be aware that IE 8 and before do not support addEventListener
, so
window.onerror
must be used. Also, the Event
object passed to error
event listeners by IE 9 does not include error information, which must be
retrieved from window.event
with non-standard property names
(errorMessage
, errorUrl
, errorLine
, errorCharacter
).
Use fetch
with keepalive
or sendBeacon
Using XMLHttpRequest is problematic because
the page may be unloaded before the request is made, causing the request to be
aborted. This is especially likely if the error occurs during the
unload
or
beforeunload
events. Previously this could be avoided by making a synchronous request
(calling open
with async
false
), but synchronous requests cause delays for the user and have been
deprecated
and will be disallowed during page dismissal in
Chrome
and in Firefox.
The preferred solution is to use fetch
with keepalive:
true
. Unfortunately,
this is not yet supported in Safari or Firefox (Bug
1342484). A more portable solution is
to use
navigator.sendBeacon
.
Unfortunately, CORS
support is in flux and Chrome
currently rejects non-CORS blob types, so making a
simple
request
(with application/x-www-form-urlencoded
, multipart/form-data
, or
text/plain
body) is recommended. Also note that Chrome sends
URLSearchParams
as text/plain
instead of application/x-www-form-urlencoded
due to Bug
747787, so using a Blob
may be necessary.
Resolve sources in stack traces
The JavaScript that is run in the browser is often the result of transforming (e.g. transpiling, bundling, minifying) source files in complex ways. The stack traces may be significantly more useful if they refer to names and locations used in the source files. This can be accomplished using information from source maps with libraries such as stacktrace.js.
Important Caveat: stacktrace.js fetches source maps and sources asynchronously, which reintroduces the problem solved in the previous section: That the page may unload before the sources are resolved, preventing the error from being reported. This can be avoided by resolving the sources on the error reporting server. Alternatively, the error could be reported twice, both before and after the sources are resolved, and the unresolved report discarded by the server when the resolved report is received.
A basic error reporting script
With the above tips in mind, here is a simplified version of the error reporting script that I came up with (which omits source resolution due to the need for server coordination):
// JavaScript error reporting functions, including automatic window.onerror
// and unhandledrejection reporting.
//
// Based on:
// https://kevinlocke.name/bits/2019/07/30/more-robust-javascript-error-reporting/
//
// API:
// reportError(message, error):
// Report an exception with optional message and exception value.
// reportRejection(message, cause):
// Report a rejection with optional message and cause.
// setReportUrl(newReportUrl):
// Set URL to which reports are POSTed as application/x-www-form-urlencoded
// Must be called before reporting any errors unless a default reportUrl is
// defined below.
//
// Note: unhandledrejection is only raised by Chrome 49+, Edge, Firefox 69+,
// and Bluebird. Others must use .catch(errorReporting.reportRejection).
// when.js and yaku users could call reportRejection on unhandledRejection.
//
// Note: This script is intended to work with IE 6+ so that errors are reported
// for incorrect Compatibility View, EMIE, and/or X-UA-Compatible settings.
//
// To the extent possible under law, Kevin Locke <kevin@kevinlocke.name> has
// waived all copyright and related or neighboring rights to this work.
// See https://creativecommons.org/publicdomain/zero/1.0/
// Universal Module Definition (UMD) for Node, AMD, and browser globals
// https://github.com/umdjs/umd/blob/master/templates/returnExports.js
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define([], factory);
} else if (typeof module === 'object' && module.exports) {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory();
} else {
// Browser globals (root is window)
root.errorReporting = factory();
}
}(typeof self !== 'undefined' ? self : this, function() {
'use strict';
var reportUrl;
// hasOwnProperty is not available from global scope in IE
// eslint-disable-next-line no-shadow
var hasOwnProperty = Object.prototype.hasOwnProperty;
function logError(/* args */) {
try {
// eslint-disable-next-line no-console
console.error.apply(console, arguments);
} catch (err) {
// Ignore failures (e.g. console not available). Can't log.
}
}
/** Gets the URL-encoding of a given string.
* https://github.com/jerrybendy/url-search-params-polyfill/blob/v7.0.0/index.js#L117
* @private
*/
function urlEncode(str) {
var replace = {
'!': '%21',
"'": '%27',
'(': '%28',
')': '%29',
'~': '%7E',
'%20': '+',
'%00': '\x00'
};
return encodeURIComponent(str)
.replace(/[!'()~]|%20|%00/g, function(match) {
return replace[match];
});
}
// eslint-disable-next-line no-shadow
var URLSearchParams = window.URLSearchParams
|| function URLSearchParamsPolyfill() {
var params = {};
this.set = function URLSearchParamsPolyfill$set(param, value) {
params[param] = value == null ? '' : String(value);
};
this.toString = function URLSearchParamsPolyfill$toString() {
var query = [];
for (var param in params) {
if (hasOwnProperty.call(params, param)) {
query.push(param + '=' + urlEncode(params[param]));
}
}
return query.join('&');
};
};
function sendReport(url, report) {
// sendBeacon support for CORS is in flux. Now spec'd as no-cors mode.
// https://bugzilla.mozilla.org/1280692
// https://bugzilla.mozilla.org/1289387
// Chrome rejects non-CORS Blob types: https://crbug.com/490015
// ScriptService doesn't support multipart/form-data, so use urlencoded
var reportParams = new URLSearchParams();
for (var reportProp in report) {
if (hasOwnProperty.call(report, reportProp)) {
var reportVal = report[reportProp];
// Omit null and undefined values since urlencoded values are strings
// and URLSearchParams encodes null as 'null', undefined as 'undefined'.
if (reportVal != null) {
reportParams.set(reportProp, reportVal);
}
}
}
var reportParamsStr = reportParams.toString();
// Use sendBeacon, when available, to ensure error report is delivered,
// even when the page is unloading, without impacting UX.
// https://groups.google.com/a/chromium.org/d/msg/blink-dev/LnqwTCiT9Gs/tO0IBO4PAwAJ
// https://bugzilla.mozilla.org/980902
// https://bugzilla.mozilla.org/1542967
//
// Note: Could use fetch with keepalive:true, where supported
// (e.g. by checking whether new Request().keepalive is undefined)
// Better CORS support, but less widely implemented.
// https://bugzilla.mozilla.org/1342484
if (typeof navigator.sendBeacon === 'function') {
// Chrome sends URLSearchParams as text/plain - https://crbug.com/747787
// convert to Blob to avoid the issue
var reportBlob = new Blob(
[reportParamsStr],
{type: 'application/x-www-form-urlencoded'}
);
// Note: Chrome throws on CORS type error. Protect with try-catch.
try {
if (navigator.sendBeacon(url, reportBlob)) {
return true;
}
} catch (errSendBeacon) {
logError('Error calling sendBeacon', errSendBeacon);
}
}
try {
// Note: IE < 7 didn't provide XMLHttpRequest, but it is available in
// IE 5 compatibility mode on IE 11. Don't bother providing a fallback.
var req = new XMLHttpRequest();
// If an error occurs during unload or beforeunload, need to send
// synchronously to ensure the request is sent before the page unloads.
// FIXME: Can window.onerror be triggered from window.beforeunload?
// If so, this check likely won't detect that case.
var isAsync = !window.event
|| (window.event.type !== 'unload'
&& window.event.type !== 'beforeunload');
req.open('POST', url, isAsync);
req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
req.send(reportParamsStr);
return true;
} catch (errXHR) {
logError('Error sending XMLHttpRequest', errXHR);
return false;
}
}
function errorEventToReport(errorEvent) {
// IE 9 uses non-standard ErrorEvent property names which are available on
// window.event but not on the event argument.
var windowEvent = window.event;
if (!errorEvent.error
&& !errorEvent.message
&& windowEvent
&& windowEvent.errorMessage) {
errorEvent = {
type: errorEvent.type,
message: windowEvent.errorMessage,
filename: windowEvent.errorUrl,
lineno: windowEvent.errorLine,
colno: windowEvent.errorCharacter
};
}
// Note: error may not be instanceof Error. Handle carefully.
var error = errorEvent.error || errorEvent.cause;
var errorString = error == null ? null : String(error);
// Note: Error.prototype.toString isn't useful on IE < 8. Detect and fix.
if (error
&& typeof error.message === 'string'
&& errorString === Object.prototype.toString.call(error)) {
if (typeof error.name === 'string') {
errorString = error.name + ': ' + error.message;
} else {
errorString = error.message;
}
}
var eventMessage =
errorEvent.message == null ? null : String(errorEvent.message);
var message =
// If there was no event message, use error string (if any)
!eventMessage ? errorString
// If there was no error string, use event message (if any)
: !errorString ? eventMessage
// If error string contains event message, use error string
// e.g. On Edge, IE eventMessage === error.message
: errorString.indexOf(eventMessage) >= 0 ? errorString
// If event message contains error string, use event message
// e.g. On Chrome eventMessage === 'Unhandled ' + errorString
: eventMessage.indexOf(errorString) >= 0 ? eventMessage
// Otherwise, combine them
: eventMessage + ': ' + errorString;
// FIXME: To get more useful stack information (especially when minified),
// consider https://github.com/stacktracejs/stacktrace.js
// May want to send unresolved stack early and resolved later, in case page
// unloads before source resolution completes.
var stack = error && error.stack;
if (stack) {
// Remove redundancy between stack and message
var nlPos = stack.indexOf('\n');
var firstLine = nlPos > 0 ? stack.slice(0, nlPos) : null;
if (firstLine === errorString) {
// First line of stack is error (Chrome, Edge, IE). Remove it.
stack = stack.slice(nlPos + 1);
}
}
if (!stack) {
if (errorEvent.filename) {
stack = ' at ' + errorEvent.filename;
if (errorEvent.lineno) {
stack += ':' + errorEvent.lineno;
if (errorEvent.colno) {
stack += ':' + errorEvent.colno;
}
}
} else {
try {
throw new Error('Reported from');
} catch (err) {
stack = err.stack;
}
}
}
return {
type: errorEvent.type,
message: message,
stack: stack,
url: location.href,
referrer: document.referrer,
// Note: May differ from header due to Compatibility View + X-UA-Compat
userAgent: navigator.userAgent
};
}
function sendError(errorEvent) {
if (!reportUrl) {
logError('Unable to send error report: Report URL not set', errorEvent);
return false;
}
return sendReport(reportUrl, errorEventToReport(errorEvent));
}
/** Reports an error to the configured report URL.
* @param {string=} message Optional error message.
* @param {*} error Optional error message.
* @return {bool} Was the report successfully sent or queued to send?
* Note: An error is logged to the console if the report can not be sent.
*/
function reportError(message, error) {
if (error == null && typeof message !== 'string') {
error = message;
message = undefined;
}
// Log error to console for parity with unhandled exceptions
logError(message || 'Reporting error', error);
return sendError({
type: 'error',
message: message == null ? undefined : String(message),
error: error
});
}
/** Reports a rejection to the configured report URL.
* @param {string=} message Optional error message.
* @param {*} cause Optional rejection cause.
* @return {bool} Was the report successfully sent or queued to send?
* Note: An error is logged to the console if the report can not be sent.
*/
function reportRejection(message, cause) {
if (cause == null && typeof message !== 'string') {
cause = message;
message = undefined;
}
// Log error to console for parity with unhandledrejection events
logError(message || 'Reporting unhandledrejection', cause);
return sendError({
type: 'unhandledrejection',
message: message == null ? undefined : String(message),
cause: cause
});
}
/** Sets the URL to which errors are reported.
* @param {string} newReportUrl URL to which errors should be reported.
*/
function setReportUrl(newReportUrl) {
reportUrl = newReportUrl;
}
if (window.addEventListener) {
window.addEventListener('error', sendError, false);
window.addEventListener('unhandledrejection', sendError, false);
} else {
var oldonerror = window.onerror;
window.onerror = function(message, filename, lineno, colno, error) {
sendError({
type: 'error',
message: message,
filename: filename,
lineno: lineno,
colno: colno,
error: error
});
return oldonerror ? oldonerror.apply(this, arguments) : false;
};
}
return {
reportError: reportError,
reportRejection: reportRejection,
setReportUrl: setReportUrl,
};
}));