Skip to content

Commit

Permalink
feat: Ignore errors (aws-observability#164)
Browse files Browse the repository at this point in the history
Co-authored-by: Quinn Hanam <hanquinn@amazon.com>
  • Loading branch information
adebayor123 and qhanam authored May 26, 2022
1 parent 5dbaa61 commit 75dee61
Show file tree
Hide file tree
Showing 7 changed files with 273 additions and 16 deletions.
21 changes: 21 additions & 0 deletions app/js_error_event.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@
throw 'thrown string';
}

function throwObserverLoopError() {
throw 'ResizeObserver loop limit exceeded';
throw new Error('ResizeObserver loop limit exceeded');
}

function recordObserverLoopError() {
cwr(
'recordError',
new Error('ResizeObserver loop limit exceeded')
);
}

function recordCaughtError() {
cwr('recordError', new Error('My error message'));
}
Expand Down Expand Up @@ -84,6 +96,15 @@
<button id="recordCaughtError" onclick="recordCaughtError()">
Record caught error
</button>
<button id="resizeObserverLoopError" onclick="throwObserverLoopError()">
Throw ResizeObserverLoopError
</button>
<button
id="recordObserverLoopError"
onclick="recordObserverLoopError()"
>
Record ResizeObserverLoopError
</button>
<button id="disable" onclick="disable()">Disable</button>
<button id="enable" onclick="enable()">Enable</button>
<hr />
Expand Down
27 changes: 25 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ For example, the following telemetry config arrays are both valid. The one on th
telemetries: [ 'errors', 'performance', 'http' ]
```
```javascript
telemetries: [
[ 'errors', { stackTraceLength: 500 } ],
telemetries: [
[ 'errors', { stackTraceLength: 500 } ],
'performance',
[ 'http', { stackTraceLength: 500, addXRayTraceIdHeader: true } ]
]
Expand All @@ -78,6 +78,29 @@ telemetries: [
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| stackTraceLength | Number | `200` | The number of characters to record from a JavaScript error's stack trace (if available). |
| ignore | Function | `() => false` | A function which accepts an [`ErrorEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent) or a [`PromiseRejectionEvent`](https://developer.mozilla.org/en-US/docs/Web/API/PromiseRejectionEvent) and returns a value that coerces to true when the error should be ignored. By default, no errors are ignored. |

For example, the following telemetry config array causes the web client to ignore all errors whose message begins with "Warning:".

```javascript
telemetries: [
[
'errors',
{
stackTraceLength: 500,
ignore: (errorEvent) => {
return (
errorEvent &&
errorEvent.message &&
errorEvent.message.test(/^Warning:/)
);
}
}
],
'performance',
'http'
]
```

## HTTP

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
"web-vitals": "^1.1.1"
},
"lint-staged": {
"*.{js,ts}": "npm run lint",
"*.{ts}": "npm run lint",
"*.{js,ts,json,html,yml,md}": "npx prettier --check"
},
"jest": {
Expand Down
13 changes: 12 additions & 1 deletion src/loader/loader-js-error-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ loader('cwr', 'abc123', '1.0', 'us-west-2', './rum_javascript_telemetry.js', {
allowCookies: true,
dispatchInterval: 0,
metaDataPluginsToLoad: [],
eventPluginsToLoad: [new JsErrorPlugin()],
eventPluginsToLoad: [
new JsErrorPlugin({
ignore: (errorEvent) => {
const patterns = [/ResizeObserver loop/];
return (
patterns.filter((pattern) =>
pattern.test(errorEvent.message)
).length !== 0
);
}
})
],
telemetries: [],
clientBuilder: showRequestClientBuilder
});
Expand Down
33 changes: 22 additions & 11 deletions src/plugins/event-plugins/JsErrorPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ export const JS_ERROR_EVENT_PLUGIN_ID = 'js-error';

export type PartialJsErrorPluginConfig = {
stackTraceLength?: number;
ignore?: (error: ErrorEvent | PromiseRejectionEvent) => boolean;
};

export type JsErrorPluginConfig = {
stackTraceLength: number;
ignore: (error: ErrorEvent | PromiseRejectionEvent) => boolean;
};

const defaultConfig: JsErrorPluginConfig = {
stackTraceLength: 200
stackTraceLength: 200,
ignore: () => false
};

export class JsErrorPlugin extends InternalPlugin {
Expand Down Expand Up @@ -42,9 +45,9 @@ export class JsErrorPlugin extends InternalPlugin {

record(error: any): void {
if (error instanceof ErrorEvent) {
this.eventHandler(error);
this.recordJsErrorEvent(error);
} else {
this.eventHandler({ type: 'error', error } as ErrorEvent);
this.recordJsErrorEvent({ type: 'error', error } as ErrorEvent);
}
}

Expand All @@ -53,19 +56,27 @@ export class JsErrorPlugin extends InternalPlugin {
}

private eventHandler = (errorEvent: ErrorEvent) => {
this.context?.record(
JS_ERROR_EVENT_TYPE,
errorEventToJsErrorEvent(errorEvent, this.config.stackTraceLength)
);
if (!this.config.ignore(errorEvent)) {
this.recordJsErrorEvent(errorEvent);
}
};

private promiseRejectEventHandler = (event: PromiseRejectionEvent) => {
this.eventHandler({
type: event.type,
error: event.reason
} as ErrorEvent);
if (!this.config.ignore(event)) {
this.recordJsErrorEvent({
type: event.type,
error: event.reason
} as ErrorEvent);
}
};

private recordJsErrorEvent(error: any) {
this.context?.record(
JS_ERROR_EVENT_TYPE,
errorEventToJsErrorEvent(error, this.config.stackTraceLength)
);
}

private addEventHandler(): void {
window.addEventListener('error', this.eventHandler);
window.addEventListener(
Expand Down
37 changes: 36 additions & 1 deletion src/plugins/event-plugins/__integ__/JsErrorPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { JS_ERROR_EVENT_TYPE } from '../../utils/constant';

const triggerTypeError: Selector = Selector(`#triggerTypeError`);
const throwErrorString: Selector = Selector(`#throwErrorString`);
const recordStackTrace: Selector = Selector(`#recordStackTrace`);
const recordCaughtError: Selector = Selector(`#recordCaughtError`);
const triggerResizeObserver: Selector = Selector(`#resizeObserverLoopError`);
const recordResizeObserver: Selector = Selector(`#recordObserverLoopError`);
const triggerPromiseRejection: Selector = Selector(`#uncaughtPromiseRejection`);

const dispatch: Selector = Selector(`#dispatch`);
Expand Down Expand Up @@ -169,3 +170,37 @@ test('when the application records a caught error then the plugin records the er
.expect(eventDetails.message)
.contains('My error message');
});

test('when ignore function matches error then the plugin does not record the error', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(triggerResizeObserver)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const json = removeUnwantedEvents(
JSON.parse(await REQUEST_BODY.textContent)
);

await t.expect(json.RumEvents.length).eql(0);
});

test('when error invoked with record method then the plugin records the error', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(recordResizeObserver)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const json = removeUnwantedEvents(
JSON.parse(await REQUEST_BODY.textContent)
);

await t.expect(json.RumEvents.length).eql(1);
});
156 changes: 156 additions & 0 deletions src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,4 +521,160 @@ describe('JsErrorPlugin tests', () => {
})
);
});

test('when record is used then errors are not passed to the ignore function', async () => {
// Init
const mockIgnore = jest.fn();
const plugin: JsErrorPlugin = new JsErrorPlugin({
ignore: mockIgnore
});

// Run
plugin.load(context);
plugin.record({
message: 'ResizeObserver loop limit exceeded'
});
plugin.disable();

// Assert
expect(record).toHaveBeenCalled();
expect(mockIgnore).not.toHaveBeenCalled();
});

test('by default ErrorEvents are not ignored', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin();

// Run
plugin.load(context);

const ignoredError = new ErrorEvent('error', {
error: new Error('Something went wrong!')
});
window.dispatchEvent(ignoredError);
plugin.disable();

// Assert
expect(record).toHaveBeenCalled();
});

test('by default PromiseRejectionEvents are not ignored', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin();

// Run
plugin.load(context);

const promiseRejectionEvent: PromiseRejectionEvent = new Event(
'unhandledrejection'
) as PromiseRejectionEvent;
window.dispatchEvent(
Object.assign(promiseRejectionEvent, {
promise: new Promise(() => ({})),
reason: {
name: 'TypeError',
message: 'NetworkError when attempting to fetch resource.',
stack: 't/n.fetch@mock_client.js:2:104522t/n.fetchWrapper'
}
})
);
plugin.disable();

// Assert
expect(record).toHaveBeenCalled();
});

test('when errors are ignored then ErrorEvents are not recorded', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin({
ignore: (e) => !!(e as ErrorEvent).error // true
});

// Run
plugin.load(context);

const ignoredError = new ErrorEvent('error', {
error: new Error('Something went wrong!')
});
window.dispatchEvent(ignoredError);
plugin.disable();

// Assert
expect(record).not.toHaveBeenCalled();
});

test('when errors are ignored then PromiseRejectionEvents are not recorded', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin({
ignore: (e) => !!(e as PromiseRejectionEvent).reason // true
});

// Run
plugin.load(context);

const promiseRejectionEvent: PromiseRejectionEvent = new Event(
'unhandledrejection'
) as PromiseRejectionEvent;
window.dispatchEvent(
Object.assign(promiseRejectionEvent, {
promise: new Promise(() => ({})),
reason: {
name: 'TypeError',
message: 'NetworkError when attempting to fetch resource.',
stack: 't/n.fetch@mock_client.js:2:104522t/n.fetchWrapper'
}
})
);
plugin.disable();

// Assert
expect(record).not.toHaveBeenCalled();
});

test('when errors are explicitly not ignored then ErrorEvents are recorded', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin({
ignore: (e) => !(e as ErrorEvent).error // false
});

// Run
plugin.load(context);

const ignoredError = new ErrorEvent('error', {
error: new Error('Something went wrong!')
});
window.dispatchEvent(ignoredError);
plugin.disable();

// Assert
expect(record).toHaveBeenCalled();
});

test('when errors are explicitly not ignored then PromiseRejectionEvents are recorded', async () => {
// Init
const plugin: JsErrorPlugin = new JsErrorPlugin({
ignore: (e) => !(e as PromiseRejectionEvent).reason // false
});

// Run
plugin.load(context);

const promiseRejectionEvent: PromiseRejectionEvent = new Event(
'unhandledrejection'
) as PromiseRejectionEvent;
window.dispatchEvent(
Object.assign(promiseRejectionEvent, {
promise: new Promise(() => ({})),
reason: {
name: 'TypeError',
message: 'NetworkError when attempting to fetch resource.',
stack: 't/n.fetch@mock_client.js:2:104522t/n.fetchWrapper'
}
})
);
plugin.disable();

// Assert
expect(record).toHaveBeenCalled();
});
});

0 comments on commit 75dee61

Please sign in to comment.