diff --git a/package-lock.json b/package-lock.json index bcc4b329..8c824b48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "@aws-sdk/signature-v4": "^3.36.0", "@aws-sdk/util-hex-encoding": "^3.36.0", "@babel/runtime": "^7.16.0", + "@types/stacktrace-js": "^2.0.3", "shimmer": "^1.2.1", + "stacktrace-js": "^2.0.2", "ua-parser-js": "^1.0.33", "uuid": "^9.0.0", "web-vitals": "^3.0.2" @@ -5478,6 +5480,15 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/stacktrace-js": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stacktrace-js/-/stacktrace-js-2.0.3.tgz", + "integrity": "sha512-B6JnMic4NAZ4mLWmRi4RvayCN2HZQvpcVF0MkoqubtuZx1AQB0/kRlrngGiocEPyO7R+TFocTEoLKQ0HzmEOPw==", + "deprecated": "This is a stub types definition. stacktrace-js provides its own type definitions, so you do not need this installed.", + "dependencies": { + "stacktrace-js": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", @@ -9222,7 +9233,6 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "dev": true, "dependencies": { "stackframe": "^1.3.4" } @@ -18861,6 +18871,14 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/stack-generator": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", + "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -18885,8 +18903,34 @@ "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "dev": true + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + }, + "node_modules/stacktrace-gps": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", + "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", + "dependencies": { + "source-map": "0.5.6", + "stackframe": "^1.3.4" + } + }, + "node_modules/stacktrace-gps/node_modules/source-map": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stacktrace-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", + "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", + "dependencies": { + "error-stack-parser": "^2.0.6", + "stack-generator": "^2.0.5", + "stacktrace-gps": "^3.0.4" + } }, "node_modules/stacktrace-parser": { "version": "0.1.10", @@ -26322,6 +26366,14 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/stacktrace-js": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stacktrace-js/-/stacktrace-js-2.0.3.tgz", + "integrity": "sha512-B6JnMic4NAZ4mLWmRi4RvayCN2HZQvpcVF0MkoqubtuZx1AQB0/kRlrngGiocEPyO7R+TFocTEoLKQ0HzmEOPw==", + "requires": { + "stacktrace-js": "*" + } + }, "@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", @@ -29135,7 +29187,6 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "dev": true, "requires": { "stackframe": "^1.3.4" } @@ -36382,6 +36433,14 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "stack-generator": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", + "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", + "requires": { + "stackframe": "^1.3.4" + } + }, "stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -36402,8 +36461,33 @@ "stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "dev": true + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + }, + "stacktrace-gps": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", + "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", + "requires": { + "source-map": "0.5.6", + "stackframe": "^1.3.4" + }, + "dependencies": { + "source-map": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==" + } + } + }, + "stacktrace-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", + "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", + "requires": { + "error-stack-parser": "^2.0.6", + "stack-generator": "^2.0.5", + "stacktrace-gps": "^3.0.4" + } }, "stacktrace-parser": { "version": "0.1.10", diff --git a/package.json b/package.json index 194b713d..941c5554 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,9 @@ "@aws-sdk/signature-v4": "^3.36.0", "@aws-sdk/util-hex-encoding": "^3.36.0", "@babel/runtime": "^7.16.0", + "@types/stacktrace-js": "^2.0.3", "shimmer": "^1.2.1", + "stacktrace-js": "^2.0.2", "ua-parser-js": "^1.0.33", "uuid": "^9.0.0", "web-vitals": "^3.0.2" diff --git a/src/orchestration/Orchestration.ts b/src/orchestration/Orchestration.ts index a94a4c15..8dc56a32 100644 --- a/src/orchestration/Orchestration.ts +++ b/src/orchestration/Orchestration.ts @@ -107,6 +107,11 @@ export type CookieAttributes = { export type PartialCookieAttributes = Partial; +export type SourceMapsFetchFunction = ( + input: RequestInfo, + init?: RequestInit +) => Promise; + export interface Config { allowCookies: boolean; batchLimit: number; @@ -159,6 +164,15 @@ export interface Config { telemetries: Telemetry[]; useBeacon: boolean; userIdRetentionDays: number; + /** + * Apply source maps to error stacks. Enabling this feature will make fetch calls + * for javascript source and map files. If these files are cross domain then CORS + * headers must be included in their responses. If credentials or other headers are + * required then provide a configured fetch function that returns the response as + * a string promise. + */ + sourceMapsEnabled?: boolean; + sourceMapsFetchFunction?: SourceMapsFetchFunction; } export interface PartialConfig diff --git a/src/plugins/event-plugins/FetchPlugin.ts b/src/plugins/event-plugins/FetchPlugin.ts index af04eb9f..70f4c9bd 100644 --- a/src/plugins/event-plugins/FetchPlugin.ts +++ b/src/plugins/event-plugins/FetchPlugin.ts @@ -250,16 +250,18 @@ export class FetchPlugin extends MonkeyPatched { } }; - private recordHttpEventWithError = ( + private recordHttpEventWithError = async ( httpEvent: HttpEvent, error: Error | string | number | boolean | undefined | null ) => { - httpEvent.error = errorEventToJsErrorEvent( + httpEvent.error = await errorEventToJsErrorEvent( { type: 'error', error } as ErrorEvent, - this.config.stackTraceLength + this.config.stackTraceLength, + this.context.config.sourceMapsEnabled, + this.context.config.sourceMapsFetchFunction ); this.context.record(HTTP_EVENT_TYPE, httpEvent); }; @@ -295,9 +297,9 @@ export class FetchPlugin extends MonkeyPatched { this.recordHttpEventWithResponse(httpEvent, response); return response; }) - .catch((error: Error) => { + .catch(async (error: Error) => { this.endTrace(trace, undefined, error); - this.recordHttpEventWithError(httpEvent, error); + await this.recordHttpEventWithError(httpEvent, error); throw error; }); }; diff --git a/src/plugins/event-plugins/JsErrorPlugin.ts b/src/plugins/event-plugins/JsErrorPlugin.ts index b0e5eba9..32dc2197 100644 --- a/src/plugins/event-plugins/JsErrorPlugin.ts +++ b/src/plugins/event-plugins/JsErrorPlugin.ts @@ -70,10 +70,15 @@ export class JsErrorPlugin extends InternalPlugin { } }; - private recordJsErrorEvent(error: ErrorEvent) { + private async recordJsErrorEvent(error: ErrorEvent) { this.context?.record( JS_ERROR_EVENT_TYPE, - errorEventToJsErrorEvent(error, this.config.stackTraceLength) + await errorEventToJsErrorEvent( + error, + this.config.stackTraceLength, + this.context.config.sourceMapsEnabled, + this.context.config.sourceMapsFetchFunction + ) ); } diff --git a/src/plugins/event-plugins/XhrPlugin.ts b/src/plugins/event-plugins/XhrPlugin.ts index d2693e66..9fb66804 100644 --- a/src/plugins/event-plugins/XhrPlugin.ts +++ b/src/plugins/event-plugins/XhrPlugin.ts @@ -269,7 +269,7 @@ export class XhrPlugin extends MonkeyPatched { } } - private recordHttpEventWithError( + private async recordHttpEventWithError( xhrDetails: XhrDetails, xhr: XMLHttpRequest, error: Error | string | number | boolean | undefined | null @@ -278,12 +278,14 @@ export class XhrPlugin extends MonkeyPatched { const httpEvent: HttpEvent = { version: '1.0.0', request: { method: xhrDetails.method, url: xhrDetails.url }, - error: errorEventToJsErrorEvent( + error: await errorEventToJsErrorEvent( { type: 'error', error } as ErrorEvent, - this.config.stackTraceLength + this.config.stackTraceLength, + this.context.config.sourceMapsEnabled, + this.context.config.sourceMapsFetchFunction ) }; if (this.isTracingEnabled()) { diff --git a/src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts b/src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts index e912c388..e1430aa5 100644 --- a/src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts +++ b/src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts @@ -1,5 +1,14 @@ +import * as StackTrace from 'stacktrace-js'; import { JsErrorPlugin } from '../JsErrorPlugin'; -import { context, getSession, record } from '../../../test-utils/test-utils'; +import { + context, + getSession, + record, + sourceMapsOnContext, + sourceMapsFetchFunctionContext, + sourceMapsFetchFunction, + waitForTick +} from '../../../test-utils/test-utils'; import { JS_ERROR_EVENT_TYPE } from '../../utils/constant'; declare global { @@ -28,10 +37,21 @@ expect.extend({ } }); +jest.mock('stacktrace-js', () => ({ + fromError: jest.fn(() => + Promise.resolve([ + 'at onclick (/path/to/feedback.js:6:6)', + 'at invokeTheCallbackFunction (/path/to/EventHandlerNonNull.js:14:28)', + 'at anonymous (/path/to/create-event-accessor.js:35:32)' + ]) + ) +})); + describe('JsErrorPlugin tests', () => { beforeEach(() => { record.mockClear(); getSession.mockClear(); + jest.clearAllMocks(); }); test('when a TypeError is thrown then the plugin records the name, message and stack', async () => { @@ -46,6 +66,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -70,6 +91,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -106,6 +128,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -131,6 +154,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect((record.mock.calls[0][1] as any).stack).toEqual(undefined); @@ -150,6 +174,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject({ @@ -177,6 +202,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject({ version: '1.0.0', @@ -201,6 +227,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject({ version: '1.0.0', @@ -225,6 +252,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject({ version: '1.0.0', @@ -254,6 +282,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(0); }); @@ -278,6 +307,7 @@ describe('JsErrorPlugin tests', () => { ); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(0); }); @@ -295,6 +325,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); }); @@ -308,6 +339,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -329,6 +361,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -358,6 +391,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -382,6 +416,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -409,6 +444,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -443,6 +479,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -474,6 +511,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -509,6 +547,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); expect(record.mock.calls[0][1]).toMatchObject( @@ -536,6 +575,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalled(); expect(mockIgnore).not.toHaveBeenCalled(); }); @@ -554,6 +594,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalled(); }); @@ -580,6 +621,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalled(); }); @@ -599,6 +641,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).not.toHaveBeenCalled(); }); @@ -627,6 +670,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).not.toHaveBeenCalled(); }); @@ -646,6 +690,7 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalled(); }); @@ -674,6 +719,69 @@ describe('JsErrorPlugin tests', () => { plugin.disable(); // Assert + await waitForTick(); expect(record).toHaveBeenCalled(); }); + + test('when a TypeError is thrown then the plugin records the name, message and stack using source maps', async (): Promise => { + // Init + document.body.innerHTML = + ''; + const plugin: JsErrorPlugin = new JsErrorPlugin(); + + // Run + plugin.load(sourceMapsOnContext); + document.getElementById('createJSError').click(); + plugin.disable(); + + // Assert + await waitForTick(); + expect(StackTrace.fromError).toHaveBeenCalled(); + expect(StackTrace.fromError).toHaveBeenCalledWith(expect.any(Error), { + ajax: undefined + }); + expect(record).toHaveBeenCalledTimes(1); + expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); + expect(record.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + version: '1.0.0', + type: 'TypeError', + message: expect.stringContaining('Cannot read'), + stack: expect.stringContaining( + 'at invokeTheCallbackFunction (/path/to/EventHandlerNonNull.js:14:28)' + ) + }) + ); + }); + + test('when a TypeError is thrown then the plugin records the name, message and stack using source maps function', async () => { + // Init + document.body.innerHTML = + ''; + const plugin: JsErrorPlugin = new JsErrorPlugin(); + + // Run + plugin.load(sourceMapsFetchFunctionContext); + document.getElementById('createJSError').click(); + plugin.disable(); + + // Assert + await waitForTick(); + expect(StackTrace.fromError).toHaveBeenCalled(); + expect(StackTrace.fromError).toHaveBeenCalledWith(expect.any(Error), { + ajax: sourceMapsFetchFunction + }); + expect(record).toHaveBeenCalledTimes(1); + expect(record.mock.calls[0][0]).toEqual(JS_ERROR_EVENT_TYPE); + expect(record.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + version: '1.0.0', + type: 'TypeError', + message: expect.stringContaining('Cannot read'), + stack: expect.stringContaining( + 'at invokeTheCallbackFunction (/path/to/EventHandlerNonNull.js:14:28)' + ) + }) + ); + }); }); diff --git a/src/plugins/utils/js-error-utils.ts b/src/plugins/utils/js-error-utils.ts index 860a3784..097cec18 100644 --- a/src/plugins/utils/js-error-utils.ts +++ b/src/plugins/utils/js-error-utils.ts @@ -1,4 +1,6 @@ +import * as StackTrace from 'stacktrace-js'; import { JSErrorEvent } from '../../events/js-error-event'; +import { SourceMapsFetchFunction } from '../../orchestration/Orchestration'; /** * Global error object. @@ -56,11 +58,35 @@ const appendErrorPrimitiveDetails = ( rumEvent.message = error.toString(); }; -const appendErrorObjectDetails = ( +const appendErrorSourceMaps = async ( + error: globalThis.Error, + fetchFunction?: SourceMapsFetchFunction +) => { + if (error && error.stack) { + try { + const stackFrames = await StackTrace.fromError(error, { + ajax: fetchFunction + } as StackTrace.StackTraceOptions); + error.stack = stackFrames.join(' \n '); + } catch (e) { + error.stack = `Parsing stack failed: ${e} \n ${error.stack}`; + } + } +}; + +const appendErrorObjectDetails = async ( rumEvent: JSErrorEvent, error: Error, - stackTraceLength: number -): void => { + stackTraceLength: number, + sourceMapsEnabled?: boolean, + sourceMapsFetchFunction?: SourceMapsFetchFunction +): Promise => { + if (sourceMapsEnabled) { + await appendErrorSourceMaps( + error as globalThis.Error, + sourceMapsFetchFunction + ); + } // error may extend Error here, but it is not guaranteed (i.e., it could // be any object) if (error.name) { @@ -90,14 +116,22 @@ export const isErrorPrimitive = (error: any): boolean => { return error !== Object(error) && error !== undefined && error !== null; }; -export const errorEventToJsErrorEvent = ( +export const errorEventToJsErrorEvent = async ( errorEvent: ErrorEvent, - stackTraceLength: number -): JSErrorEvent => { + stackTraceLength: number, + sourceMapsEnabled?: boolean, + sourceMapsFetchFunction?: SourceMapsFetchFunction +): Promise => { const rumEvent: JSErrorEvent = buildBaseJsErrorEvent(errorEvent); const error = errorEvent.error; if (isObject(error)) { - appendErrorObjectDetails(rumEvent, error, stackTraceLength); + await appendErrorObjectDetails( + rumEvent, + error, + stackTraceLength, + sourceMapsEnabled, + sourceMapsFetchFunction + ); } else if (isErrorPrimitive(error)) { appendErrorPrimitiveDetails(rumEvent, error); } diff --git a/src/test-utils/test-utils.ts b/src/test-utils/test-utils.ts index ff54f04a..c907d426 100644 --- a/src/test-utils/test-utils.ts +++ b/src/test-utils/test-utils.ts @@ -3,7 +3,8 @@ import { AwsCredentialIdentity } from '@aws-sdk/types'; import { Config, defaultConfig, - defaultCookieAttributes + defaultCookieAttributes, + SourceMapsFetchFunction } from '../orchestration/Orchestration'; import { GetSession, @@ -110,6 +111,9 @@ export const getSession: jest.MockedFunction = jest.fn(() => ({ eventCount: 0 })); +export const sourceMapsFetchFunction: jest.MockedFunction = + jest.fn(); + export const context: PluginContext = { applicationId: 'b', applicationVersion: '1.0', @@ -130,6 +134,19 @@ export const xRayOnContext: PluginContext = { config: { ...DEFAULT_CONFIG, ...{ enableXRay: true } } }; +export const sourceMapsOnContext: PluginContext = { + ...context, + config: { ...DEFAULT_CONFIG, ...{ sourceMapsEnabled: true } } +}; + +export const sourceMapsFetchFunctionContext: PluginContext = { + ...context, + config: { + ...DEFAULT_CONFIG, + ...{ sourceMapsEnabled: true, sourceMapsFetchFunction } + } +}; + export const stringToUtf16 = (inputString: string) => { const utf16array = []; for (let index = 0; index < inputString.length; index++) { @@ -208,3 +225,11 @@ export const mockFetchWithErrorObjectAndStack = jest.fn( stack: 'stack trace' }) ); + +export const waitForTick = () => { + return new Promise((resolve) => { + process.nextTick(() => { + resolve(''); + }); + }); +};