Commit 59d2f213 authored by Robert Knight's avatar Robert Knight

Add infrastructure for capturing host-frame errors in Sentry

Add infrastructure that will allow us to gain visibility into errors
happening in the host frame, in specifically wrapped blocks of code, by
capturing the errors and forwarding them to the sidebar.

The initial implementation requires that the browser is able to clone
errors in `window.postMessage` calls, and currently only Chrome supports
that. In other browsers forwarding errors will fail with a warning.

 - Add `shared/frame-error-capture.js` module with functions for
   capturing errors, registering a target frame to receive them and
   forwarding errors to the frame.

 - Register sidebar application as handler for errors in host frame in
   `Sidebar` constructor and de-register it in the destructor.

 - Call `handleErrorsInFrames` in `sidebar/util/sentry.js` to handle
   errors from host frame by sending them to Sentry.
parent 7371ab9b
......@@ -2,6 +2,7 @@ import Hammer from 'hammerjs';
import { Bridge } from '../shared/bridge';
import { addConfigFragment } from '../shared/config-fragment';
import { sendErrorsTo } from '../shared/frame-error-capture';
import { ListenerCollection } from '../shared/listener-collection';
import { annotationCounts } from './annotation-counts';
......@@ -113,6 +114,11 @@ export default class Sidebar {
element.appendChild(this.hypothesisSidebar);
}
// Register the sidebar as a handler for Hypothesis errors in this frame.
if (this.iframe.contentWindow) {
sendErrorsTo(this.iframe.contentWindow);
}
this.guest = guest;
this._listeners = new ListenerCollection();
......@@ -217,6 +223,9 @@ export default class Sidebar {
this.iframe.remove();
}
this._emitter.destroy();
// Unregister the sidebar iframe as a handler for errors in this frame.
sendErrorsTo(null);
}
/**
......
......@@ -24,6 +24,7 @@ describe('Sidebar', () => {
let fakeGuest;
let FakeToolbarController;
let fakeToolbar;
let fakeSendErrorsTo;
before(() => {
sinon.stub(window, 'requestAnimationFrame').yields();
......@@ -110,6 +111,8 @@ describe('Sidebar', () => {
};
FakeToolbarController = sinon.stub().returns(fakeToolbar);
fakeSendErrorsTo = sinon.stub();
const fakeCreateAppConfig = sinon.spy(config => {
const appConfig = { ...config };
delete appConfig.sidebarAppUrl;
......@@ -118,6 +121,7 @@ describe('Sidebar', () => {
$imports.$mock({
'../shared/bridge': { Bridge: sinon.stub().returns(fakeBridge) },
'../shared/frame-error-capture': { sendErrorsTo: fakeSendErrorsTo },
'./bucket-bar': { default: FakeBucketBar },
'./config/app': { createAppConfig: fakeCreateAppConfig },
'./toolbar': {
......@@ -174,6 +178,11 @@ describe('Sidebar', () => {
});
});
it('registers sidebar app as a handler for errors in the host frame', () => {
const sidebar = createSidebar();
assert.calledWith(fakeSendErrorsTo, sidebar.iframe.contentWindow);
});
it('notifies sidebar app when a guest frame is unloaded', () => {
createSidebar();
......@@ -533,6 +542,15 @@ describe('Sidebar', () => {
sidebar.destroy();
assert.called(sidebar.bucketBar.destroy);
});
it('unregisters sidebar as handler for host frame errors', () => {
const sidebar = createSidebar();
fakeSendErrorsTo.resetHistory();
sidebar.destroy();
assert.calledWith(fakeSendErrorsTo, null);
});
});
describe('#onFrameConnected', () => {
......
/** @type {Window|null} */
let errorDestination = null;
/**
* Wrap a callback with an error handler which forwards errors to another frame
* using {@link sendError}.
*
* @template {unknown[]} Args
* @template Result
* @param {(...args: Args) => Result} callback
* @param {string} context - A short message indicating where the error happened.
* @return {(...args: Args) => Result}
*/
export function captureErrors(callback, context) {
return (...args) => {
try {
return callback(...args);
} catch (err) {
sendError(err, context);
throw err;
}
};
}
/**
* Forward an error to the frame registered with {@link sendErrorsTo}.
*
* This function operates on a best-effort basis. If no error handling frame
* has been registered it does nothing.
*
* @param {unknown} error
* @param {string} context - A short message indicating where the error happened.
*/
export function sendError(error, context) {
if (!errorDestination) {
return;
}
const data = { type: 'hypothesis-error', error, context };
try {
// Try to send the error. This will currently fail in browsers which don't
// support structured cloning of exceptions. For these we'll need to implement
// a fallback.
errorDestination.postMessage(data, '*');
} catch (sendErr) {
console.warn('Unable to report Hypothesis error', sendErr);
}
}
/**
* Register a handler for errors sent to the current frame using {@link sendError}
*
* @param {(error: unknown, context: string) => void} callback
* @return {() => void} A function that unregisters the handler
*/
export function handleErrorsInFrames(callback) {
/** @param {MessageEvent} event */
const handleMessage = event => {
const { data } = event;
if (data && data?.type === 'hypothesis-error') {
callback(data.error, data.context);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}
/**
* Register a destination frame that {@link sendError} should submit errors to.
*
* @param {Window|null} destination
*/
export function sendErrorsTo(destination) {
errorDestination = destination;
}
import { delay } from '../../test-util/wait';
import {
captureErrors,
handleErrorsInFrames,
sendError,
sendErrorsTo,
} from '../frame-error-capture';
describe('shared/frame-error-capture', () => {
let errorEvents;
function handleMessage(event) {
if (event.data.type === 'hypothesis-error') {
errorEvents.push(event.data);
}
}
beforeEach(() => {
errorEvents = [];
window.addEventListener('message', handleMessage);
});
afterEach(() => {
window.removeEventListener('message', handleMessage);
sendErrorsTo(null);
});
describe('captureErrors', () => {
it('returns wrapped callback', () => {
const callback = captureErrors((a, b) => a * b, 'Testing captureErrors');
const result = callback(7, 8);
assert.equal(result, 56);
});
it('captures errors and forwards them to the current handler frame', async () => {
sendErrorsTo(window);
const error = new Error('Test error');
const callback = captureErrors(() => {
throw error;
}, 'Testing captureErrors');
assert.throws(() => {
callback();
}, 'Test error');
await delay(0);
assert.equal(errorEvents.length, 1);
assert.match(errorEvents[0], {
context: 'Testing captureErrors',
error: sinon.match({ message: 'Test error' }),
type: 'hypothesis-error',
});
});
it('does not forward errors if there is no handler frame', () => {
const callback = captureErrors(() => {
throw new Error('Test error');
}, 'Testing captureErrors');
assert.throws(() => {
callback();
}, 'Test error');
assert.equal(errorEvents.length, 0);
});
it('ignores errors forwarding error to handler frame', () => {
try {
sinon
.stub(window, 'postMessage')
.throws(new Error('postMessage error'));
sinon.stub(console, 'warn');
sendErrorsTo(window);
const callback = captureErrors(() => {
throw new Error('Test error');
}, 'Testing captureErrors');
assert.throws(() => {
callback();
}, 'Test error');
assert.calledOnce(window.postMessage);
assert.calledOnce(console.warn);
} finally {
console.warn.restore();
window.postMessage.restore();
}
});
});
describe('handleErrorsInFrames', () => {
it('invokes callback when an error is received', async () => {
const receivedErrors = [];
const removeHandler = handleErrorsInFrames((error, context) => {
receivedErrors.push({ error, context });
});
try {
sendErrorsTo(window);
sendError(new Error('Test error'), 'Test context');
await delay(0);
assert.equal(receivedErrors.length, 1);
assert.equal(receivedErrors[0].error.message, 'Test error');
assert.equal(receivedErrors[0].context, 'Test context');
} finally {
removeHandler();
}
});
});
// Integration test that combines `captureErrors` and `handleErrorsInFrames`.
it('captures and forwards errors from wrapped callbacks', async () => {
const receivedErrors = [];
const removeHandler = handleErrorsInFrames((error, context) => {
receivedErrors.push({ error, context });
});
try {
sendErrorsTo(window);
const callback = captureErrors(() => {
throw new Error('Test error');
}, 'Test context');
try {
callback();
} catch {
// Ignored
}
await delay(0);
assert.equal(receivedErrors.length, 1);
assert.equal(receivedErrors[0].error.message, 'Test error');
assert.equal(receivedErrors[0].context, 'Test context');
} finally {
removeHandler();
}
});
});
import * as Sentry from '@sentry/browser';
import { parseConfigFragment } from '../../shared/config-fragment';
import { handleErrorsInFrames } from '../../shared/frame-error-capture';
import warnOnce from '../../shared/warn-once';
/**
......@@ -12,6 +13,9 @@ import warnOnce from '../../shared/warn-once';
let eventsSent = 0;
const maxEventsToSendPerSession = 5;
/** @type {() => void} */
let removeFrameErrorHandler;
/**
* Return the origin which the current script comes from.
*
......@@ -119,6 +123,15 @@ export function init(config) {
.filter(isJavaScript)
.map(script => script.src || '<inline>');
Sentry.setExtra('loaded_scripts', loadedScripts);
// Catch errors occuring in Hypothesis-related code in the host frame.
removeFrameErrorHandler = handleErrorsInFrames((err, context) => {
Sentry.captureException(err, {
tags: {
context,
},
});
});
}
/**
......@@ -137,4 +150,5 @@ export function setUserInfo(user) {
*/
export function reset() {
eventsSent = 0;
removeFrameErrorHandler?.();
}
......@@ -3,14 +3,17 @@ import * as sentry from '../sentry';
describe('sidebar/util/sentry', () => {
let fakeDocumentReferrer;
let fakeDocumentCurrentScript;
let fakeHandleErrorsInFrames;
let fakeParseConfigFragment;
let fakeSentry;
let fakeWarnOnce;
beforeEach(() => {
fakeHandleErrorsInFrames = sinon.stub().returns(() => {});
fakeParseConfigFragment = sinon.stub().returns({});
fakeSentry = {
captureException: sinon.stub(),
init: sinon.stub(),
setExtra: sinon.stub(),
setUser: sinon.stub(),
......@@ -23,6 +26,9 @@ describe('sidebar/util/sentry', () => {
'../../shared/config-fragment': {
parseConfigFragment: fakeParseConfigFragment,
},
'../../shared/frame-error-capture': {
handleErrorsInFrames: fakeHandleErrorsInFrames,
},
'../../shared/warn-once': fakeWarnOnce,
});
});
......@@ -198,6 +204,21 @@ describe('sidebar/util/sentry', () => {
// be omitted from the report.
assert.deepEqual(event.extra, {});
});
it('registers a handler for errors in other frames', () => {
sentry.init({ dsn: 'test-dsn' });
assert.calledOnce(fakeHandleErrorsInFrames);
const callback = fakeHandleErrorsInFrames.getCall(0).args[0];
const error = new Error('Some error in host frame');
const context = 'some-context';
callback(error, context);
assert.calledWith(fakeSentry.captureException, error, {
tags: { context },
});
});
});
describe('setUserInfo', () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment