diff --git a/package-lock.json b/package-lock.json index 91abc783e1..c076574cb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2012,6 +2012,35 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@bugsnag/browser": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@bugsnag/browser/-/browser-6.5.2.tgz", + "integrity": "sha512-XFKKorJc92ivLnlHHhLiPvkP03tZ5y7n0Z2xO6lOU7t+jWF5YapgwqQAda/TWvyYO38B/baWdnOpWMB3QmjhkA==", + "dev": true + }, + "node_modules/@bugsnag/js": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@bugsnag/js/-/js-6.5.2.tgz", + "integrity": "sha512-4ibw624fM5+Y/WSuo3T/MsJVtslsPV8X0MxFuRxdvpKVUXX216d8hN8E/bG4hr7aipqQOGhBYDqSzeL2wgmh0Q==", + "dev": true, + "dependencies": { + "@bugsnag/browser": "^6.5.2", + "@bugsnag/node": "^6.5.2" + } + }, + "node_modules/@bugsnag/node": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@bugsnag/node/-/node-6.5.2.tgz", + "integrity": "sha512-KQ1twKoOttMCYsHv7OXUVsommVcrk6RGQ5YoZGlTbREhccbzsvjbiXPKiY31Qc7OXKvaJwSXhnOKrQTpRleFUg==", + "dev": true, + "dependencies": { + "byline": "^5.0.0", + "error-stack-parser": "^2.0.2", + "iserror": "^0.0.2", + "pump": "^3.0.0", + "stack-generator": "^2.0.3" + } + }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.0.tgz", @@ -8271,6 +8300,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -14453,6 +14491,12 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true }, + "node_modules/iserror": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/iserror/-/iserror-0.0.2.tgz", + "integrity": "sha512-oKGGrFVaWwETimP3SiWwjDeY27ovZoyZPHtxblC4hCq9fXxed/jasx+ATWFFjCVSRZng8VTMsN1nDnGo6zMBSw==", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -23045,6 +23089,15 @@ "node": ">=16 || 14 >=14.17" } }, + "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==", + "dev": true, + "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", @@ -25089,7 +25142,9 @@ "error-stack-parser": "2.1.4", "ramda": "0.30.1" }, - "devDependencies": {} + "devDependencies": { + "@bugsnag/js": "6.5.2" + } }, "packages/analytics-js-service-worker": { "name": "@rudderstack/analytics-js-service-worker", diff --git a/packages/analytics-js-common/src/types/ErrorHandler.ts b/packages/analytics-js-common/src/types/ErrorHandler.ts index 705ff5e601..6b8e3b1cd7 100644 --- a/packages/analytics-js-common/src/types/ErrorHandler.ts +++ b/packages/analytics-js-common/src/types/ErrorHandler.ts @@ -2,6 +2,7 @@ import type { IPluginEngine } from './PluginEngine'; import type { ILogger } from './Logger'; import type { BufferQueue } from '../services/BufferQueue/BufferQueue'; import type { IHttpClient } from './HttpClient'; +import type { IExternalSrcLoader } from '../services/ExternalSrcLoader/types'; export type SDKError = unknown | Error | ErrorEvent | Event | PromiseRejectionEvent; @@ -9,7 +10,7 @@ export interface IErrorHandler { logger?: ILogger; pluginEngine?: IPluginEngine; errorBuffer: BufferQueue; - init(httpClient?: IHttpClient): void; + init(httpClient: IHttpClient, externalSrcLoader: IExternalSrcLoader): void; onError( error: SDKError, context?: string, diff --git a/packages/analytics-js-common/src/types/PluginsManager.ts b/packages/analytics-js-common/src/types/PluginsManager.ts index f33ee257ee..ca562c4236 100644 --- a/packages/analytics-js-common/src/types/PluginsManager.ts +++ b/packages/analytics-js-common/src/types/PluginsManager.ts @@ -13,6 +13,7 @@ export interface IPluginsManager { export type PluginName = | 'BeaconQueue' + | 'Bugsnag' | 'CustomConsentManager' | 'DeviceModeDestinations' | 'DeviceModeTransformation' diff --git a/packages/analytics-js-plugins/.size-limit.mjs b/packages/analytics-js-plugins/.size-limit.mjs index 7d96a94773..02700fa98c 100644 --- a/packages/analytics-js-plugins/.size-limit.mjs +++ b/packages/analytics-js-plugins/.size-limit.mjs @@ -11,6 +11,6 @@ export default [ { name: 'Remote Module Federated Plugins - CDN', path: 'dist/cdn/modern/plugins/rsa-plugins-*.js', - limit: '7.5 KiB', + limit: '8.5 KiB', }, ]; diff --git a/packages/analytics-js-plugins/__fixtures__/msw.handlers.js b/packages/analytics-js-plugins/__fixtures__/msw.handlers.js index c03d6af437..292c3e773c 100644 --- a/packages/analytics-js-plugins/__fixtures__/msw.handlers.js +++ b/packages/analytics-js-plugins/__fixtures__/msw.handlers.js @@ -40,6 +40,11 @@ const handlers = [ status: 500, }); }), + http.get(`https://asdf.com/bugsnag.min.js`, () => { + return new HttpResponse(errorMessage, { + status: 404, + }); + }), ]; export { handlers }; diff --git a/packages/analytics-js-plugins/__tests__/bugsnag/index.test.ts b/packages/analytics-js-plugins/__tests__/bugsnag/index.test.ts new file mode 100644 index 0000000000..4c6674bc45 --- /dev/null +++ b/packages/analytics-js-plugins/__tests__/bugsnag/index.test.ts @@ -0,0 +1,110 @@ +import { signal } from '@preact/signals-core'; +import { clone } from 'ramda'; +import { Bugsnag } from '../../src/bugsnag'; +import * as bugsnagConstants from '../../src/bugsnag/constants'; + +describe('Plugin - Bugsnag', () => { + const originalState = { + plugins: { + loadedPlugins: signal([]), + }, + lifecycle: { + writeKey: signal('dummy-write-key'), + }, + source: signal({ + id: 'test-source-id', + }), + context: { + app: signal({ + name: 'test-app', + namespace: 'test-namespace', + version: '1.0.0', + installType: 'npm', + }), + }, + }; + + let state: any; + + const origApiKey = bugsnagConstants.API_KEY; + const origMaxSDKWait = bugsnagConstants.MAX_WAIT_FOR_SDK_LOAD_MS; + + const mountBugsnagSDK = () => { + (window as any).bugsnag = jest.fn(() => ({ + notifier: { version: '6.0.0' }, + leaveBreadcrumb: jest.fn(), + notify: jest.fn(), + })); + return (window as any).bugsnag(); + }; + + beforeEach(() => { + state = clone(originalState); + delete (window as any).bugsnag; + bugsnagConstants.API_KEY = origApiKey; + bugsnagConstants.MAX_WAIT_FOR_SDK_LOAD_MS = origMaxSDKWait; + }); + + it('should add Bugsnag plugin in the loaded plugin list', () => { + Bugsnag().initialize(state); + expect(state.plugins.loadedPlugins.value.includes('Bugsnag')).toBe(true); + }); + + it('should reject the promise if the Api Key is not valid', async () => { + bugsnagConstants.API_KEY = '{{ dummy api key }}'; + + const pluginInitPromise = Bugsnag().errorReportingProvider.init(); + + await expect(pluginInitPromise).rejects.toThrow( + 'The Bugsnag API key ({{ dummy api key }}) is invalid or not provided.', + ); + }); + + it('should reject the promise if the Bugsnag client could not be initialized', async () => { + bugsnagConstants.MAX_WAIT_FOR_SDK_LOAD_MS = 1000; + + const mockExtSrcLoader = { + loadJSFile: jest.fn(() => Promise.resolve()), + }; + + const pluginInitPromise = Bugsnag().errorReportingProvider.init(state, mockExtSrcLoader); + + await expect(pluginInitPromise).rejects.toThrow( + 'A timeout 1000 ms occurred while trying to load the Bugsnag SDK.', + ); + }); + + it('should initialize the Bugsnag SDK and return the client instance', async () => { + setTimeout(() => { + mountBugsnagSDK(); + }, 500); + + const mockExtSrcLoader = { + loadJSFile: jest.fn(() => Promise.resolve()), + }; + + const pluginInitPromise = Bugsnag().errorReportingProvider.init(state, mockExtSrcLoader); + + await expect(pluginInitPromise).resolves.toBeDefined(); + }); + + it('should notify the client', () => { + const bsClient = mountBugsnagSDK(); + + const mockError = new Error('Test Error'); + + Bugsnag().errorReportingProvider.notify(bsClient, mockError); + + expect(bsClient.notify).toHaveBeenCalledWith(mockError); + }); + + it('should leave a breadcrumb', () => { + const bsClient = mountBugsnagSDK(); + + const mockMessage = 'Test Breadcrumb'; + + Bugsnag().errorReportingProvider.breadcrumb(bsClient, mockMessage); + + expect(bsClient.leaveBreadcrumb).toHaveBeenCalledWith(mockMessage); + }); +}); diff --git a/packages/analytics-js-plugins/__tests__/bugsnag/utils.test.ts b/packages/analytics-js-plugins/__tests__/bugsnag/utils.test.ts new file mode 100644 index 0000000000..6d58890430 --- /dev/null +++ b/packages/analytics-js-plugins/__tests__/bugsnag/utils.test.ts @@ -0,0 +1,712 @@ +/* eslint-disable max-classes-per-file */ +import { signal } from '@preact/signals-core'; +import { ExternalSrcLoader } from '@rudderstack/analytics-js-common/services/ExternalSrcLoader'; +import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; +import type { IErrorHandler } from '@rudderstack/analytics-js-common/types/ErrorHandler'; +import * as timeouts from '@rudderstack/analytics-js-common/src/constants/timeouts'; +import type { ApplicationState } from '@rudderstack/analytics-js-common/types/ApplicationState'; +import * as bugsnagConstants from '../../src/bugsnag/constants'; +import { + isApiKeyValid, + getGlobalBugsnagLibInstance, + getReleaseStage, + isValidVersion, + isRudderSDKError, + enhanceErrorEventMutator, + initBugsnagClient, + loadBugsnagSDK, + onError, + getAppStateForMetadata, +} from '../../src/bugsnag/utils'; +import { server } from '../../__fixtures__/msw.server'; +import type { BugsnagLib } from '../../src/types/plugins'; + +let state: ApplicationState; + +beforeEach(() => { + window.RudderSnippetVersion = '3.0.0'; + state = { + context: { + app: signal({ + name: 'test-app', + namespace: 'test-namespace', + version: '1.0.0', + installType: 'npm', + }), + }, + source: signal({ + id: 'dummy-source-id', + }), + lifecycle: { + writeKey: signal('dummy-write-key'), + }, + }; +}); + +afterEach(() => { + window.RudderSnippetVersion = undefined; +}); + +describe('Bugsnag utilities', () => { + class MockLogger implements ILogger { + warn = jest.fn(); + log = jest.fn(); + error = jest.fn(); + info = jest.fn(); + debug = jest.fn(); + minLogLevel = 0; + scope = 'test scope'; + setMinLogLevel = jest.fn(); + setScope = jest.fn(); + logProvider = console; + } + + class MockErrorHandler implements IErrorHandler { + init = jest.fn(); + onError = jest.fn(); + leaveBreadcrumb = jest.fn(); + notifyError = jest.fn(); + } + + describe('isApiKeyValid', () => { + it('should return true for a valid API key', () => { + const apiKey = '1234567890abcdef'; + expect(isApiKeyValid(apiKey)).toBe(true); + }); + + it('should return false for an invalid API key', () => { + const apiKey = '{{invalid-api-key}}'; + expect(isApiKeyValid(apiKey)).toBe(false); + }); + + it('should return false for an invalid API key', () => { + const apiKey = ''; + expect(isApiKeyValid(apiKey)).toBe(false); + }); + }); + + describe('getGlobalBugsnagLibInstance', () => { + it('should return the global Bugsnag instance if defined on the window object', () => { + const bsObj = { + version: '1.2.3', + }; + (window as any).bugsnag = bsObj; + + expect(getGlobalBugsnagLibInstance()).toBe(bsObj); + + delete (window as any).bugsnag; + }); + + it('should return undefined if the global Bugsnag instance is not defined on the window object', () => { + expect(getGlobalBugsnagLibInstance()).toBe(undefined); + }); + }); + + describe('getReleaseStage', () => { + let windowSpy: any; + let documentSpy: any; + let navigatorSpy: any; + let locationSpy: any; + + beforeEach(() => { + windowSpy = jest.spyOn(window, 'window', 'get'); + locationSpy = jest.spyOn(globalThis, 'location', 'get'); + }); + + afterEach(() => { + windowSpy.mockRestore(); + locationSpy.mockRestore(); + }); + + const testCaseData = [ + ['localhost', 'development'], + ['127.0.0.1', 'development'], + ['www.test-host.com', 'development'], + ['[::1]', 'development'], + ['', '__RS_BUGSNAG_RELEASE_STAGE__'], + ['www.validhost.com', '__RS_BUGSNAG_RELEASE_STAGE__'], + ]; + + it.each(testCaseData)( + 'if window host name is "%s" then it should return the release stage as "%s" ', + (hostName, expectedReleaseStage) => { + locationSpy.mockImplementation(() => ({ + hostname: hostName, + })); + + expect(getReleaseStage()).toBe(expectedReleaseStage); + }, + ); + }); + + describe('isValidVersion', () => { + it('should return true if bugsnag version 6 is present in window scope', () => { + (window as any).bugsnag = jest.fn(() => ({ notifier: { version: '6.0.0' } })); + + expect(isValidVersion((window as any).bugsnag)).toBe(true); + + delete (window as any).bugsnag; + }); + + it('should return false if bugsnag version 7 is present in window scope', () => { + (window as any).bugsnag = { _client: { _notifier: { version: '7.0.0' } } }; + + expect(isValidVersion((window as any).bugsnag)).toBe(false); + + delete (window as any).bugsnag; + }); + + it('should return false if bugsnag version 4 is present in window scope', () => { + (window as any).bugsnag = jest.fn(() => ({ notifier: { version: '4.0.0' } })); + + expect(isValidVersion((window as any).bugsnag)).toBe(false); + + delete (window as any).bugsnag; + }); + }); + + describe('isRudderSDKError', () => { + const testCaseData = [ + ['https://invalid-domain.com/rsa.min.js', true], + ['https://invalid-domain.com/rss.min.js', false], + ['https://invalid-domain.com/rsa-plugins-Beacon.min.js', true], + ['https://invalid-domain.com/Amplitude.min.js', false], + ['https://invalid-domain.com/js-integrations/Amplitude.min.js', true], + ['https://invalid-domain.com/js-integrations/Qualaroo.min.js', true], + ['https://invalid-domain.com/test.js', false], + ['https://invalid-domain.com/rsa.css', false], + [undefined, false], + [null, false], + [1, false], + ['', false], + ['asdf.com', false], + ]; + + it.each(testCaseData)( + 'if script src is "%s" then it should return the value as "%s" ', + (scriptSrc, expectedValue) => { + // Bugsnag error event object structure + const event = { + stacktrace: [ + { + file: scriptSrc, + }, + ], + }; + + expect(isRudderSDKError(event)).toBe(expectedValue); + }, + ); + }); + + describe('enhanceErrorEventMutator', () => { + it('should return the enhanced error event object', () => { + const event = { + metadata: {}, + stacktrace: [ + { + file: 'https://invalid-domain.com/rsa.min.js', + }, + ], + updateMetaData(key, value) { + this.metadata[key] = value; + }, + errorMessage: 'test error message', + }; + + enhanceErrorEventMutator(state, event); + + expect(event.metadata).toEqual({ + source: { + snippetVersion: '3.0.0', + }, + state: { + source: { + id: 'dummy-source-id', + }, + lifecycle: { + writeKey: 'dummy-write-key', + }, + context: { + app: { + name: 'test-app', + namespace: 'test-namespace', + version: '1.0.0', + installType: 'npm', + }, + }, + }, + }); + + expect(event.context).toBe('test error message'); + expect(event.severity).toBe('error'); + }); + + it('should return the enhanced error event object if the error is for script loads', () => { + const event = { + metadata: {}, + stacktrace: [ + { + file: 'https://invalid-domain.com/rsa.min.js', + }, + ], + updateMetaData(key, value) { + this.metadata[key] = value; + }, + errorMessage: 'error in script loading "https://invalid-domain.com/rsa.min.js"', + }; + + enhanceErrorEventMutator(state, event, 'dummyMetadataVal'); + + expect(event.metadata).toEqual({ + source: { + snippetVersion: '3.0.0', + }, + state: { + source: { + id: 'dummy-source-id', + }, + lifecycle: { + writeKey: 'dummy-write-key', + }, + context: { + app: { + name: 'test-app', + namespace: 'test-namespace', + version: '1.0.0', + installType: 'npm', + }, + }, + }, + }); + + expect(event.context).toBe('Script load failures'); + expect(event.severity).toBe('error'); + }); + }); + + describe('initBugsnagClient', () => { + const origSdkMaxWait = bugsnagConstants.MAX_WAIT_FOR_SDK_LOAD_MS; + + const mountBugsnagSDK = () => { + (window as any).bugsnag = jest.fn(() => ({ notifier: { version: '6.0.0' } })); + }; + + afterEach(() => { + delete (window as any).bugsnag; + bugsnagConstants.MAX_WAIT_FOR_SDK_LOAD_MS = origSdkMaxWait; + state = undefined; + }); + + it('should resolve the promise immediately if the bugsnag SDK is already loaded', async () => { + mountBugsnagSDK(); + + const bsClient = await new Promise((resolve, reject) => { + initBugsnagClient(state, resolve, reject); + }); + + expect(bsClient).toBeDefined(); + }); + + it('should resolve the promise after some time when the bugsnag SDK is loaded', async () => { + setTimeout(() => { + mountBugsnagSDK(); + }, 1000); + + const bsClientPromise: Promise = new Promise((resolve, reject) => { + initBugsnagClient(state, resolve, reject); + }); + + const bsClient: BugsnagLib.Client = await bsClientPromise; + + expect(bsClient).toBeDefined(); // returns a mocked Bugsnag client + + // First call is the version check + expect((window as any).bugsnag).toHaveBeenCalledTimes(2); + expect((window as any).bugsnag).toHaveBeenNthCalledWith(2, { + apiKey: '__RS_BUGSNAG_API_KEY__', + appVersion: '1.0.0', + metaData: { + SDK: { + name: 'JS', + installType: 'npm', + }, + }, + autoCaptureSessions: false, + collectUserIp: false, + maxEvents: 100, + maxBreadcrumbs: 40, + releaseStage: 'development', + user: { + id: 'dummy-source-id', + }, + networkBreadcrumbsEnabled: false, + beforeSend: expect.any(Function), + logger: undefined, + }); + }); + + it('should return bugsnag client with write key as user id if source id is not available', async () => { + state.source.value = { id: undefined }; + state.lifecycle.writeKey = signal('dummy-write-key'); + + setTimeout(() => { + mountBugsnagSDK(); + }, 1000); + + const bsClientPromise: Promise = new Promise((resolve, reject) => { + initBugsnagClient(state, resolve, reject); + }); + + await bsClientPromise; + + // First call is the version check + expect((window as any).bugsnag).toHaveBeenCalledTimes(2); + expect((window as any).bugsnag).toHaveBeenNthCalledWith(2, { + apiKey: '__RS_BUGSNAG_API_KEY__', + appVersion: '1.0.0', + metaData: { + SDK: { + name: 'JS', + installType: 'npm', + }, + }, + autoCaptureSessions: false, + collectUserIp: false, + maxEvents: 100, + maxBreadcrumbs: 40, + releaseStage: 'development', + user: { + id: 'dummy-write-key', + }, + networkBreadcrumbsEnabled: false, + beforeSend: expect.any(Function), + logger: undefined, + }); + }); + + it('should reject the promise if the Bugsnag SDK is not loaded', async () => { + bugsnagConstants.MAX_WAIT_FOR_SDK_LOAD_MS = 1000; + + const bsClientPromise = new Promise((resolve, reject) => { + initBugsnagClient(state, resolve, reject); + }); + + await expect(bsClientPromise).rejects.toThrow( + 'A timeout 1000 ms occurred while trying to load the Bugsnag SDK.', + ); + }); + }); + + describe('loadBugsnagSDK', () => { + beforeAll(() => { + server.listen(); + }); + + afterAll(() => { + server.close(); + }); + let insertBeforeSpy: any; + + const mockLogger = new MockLogger(); + const mockErrorHandler = new MockErrorHandler(); + const extSrcLoader = new ExternalSrcLoader(mockErrorHandler, mockLogger); + + const origBugsnagUrl = bugsnagConstants.BUGSNAG_CDN_URL; + const origExtSrcLoadTimeout = timeouts.DEFAULT_EXT_SRC_LOAD_TIMEOUT_MS; + + beforeEach(() => { + insertBeforeSpy = jest.spyOn(document.head, 'insertBefore'); + }); + + afterEach(() => { + insertBeforeSpy.mockRestore(); + if (document.head.firstChild) { + document.head.removeChild(document.head.firstChild as ChildNode); + } + delete (window as any).Bugsnag; + delete (window as any).bugsnag; + bugsnagConstants.BUGSNAG_CDN_URL = origBugsnagUrl; + timeouts.DEFAULT_EXT_SRC_LOAD_TIMEOUT_MS = origExtSrcLoadTimeout; + }); + + it('should not load Bugsnag SDK if it (<=v6) is already loaded', () => { + (window as any).bugsnag = jest.fn(() => ({ notifier: { version: '6.0.0' } })); + + loadBugsnagSDK(); + + expect(insertBeforeSpy).not.toHaveBeenCalled(); + }); + + it('should not load Bugsnag SDK if it (>v6) is already loaded', () => { + (window as any).Bugsnag = { _client: { _notifier: { version: '7.0.0' } } }; + + loadBugsnagSDK(); + + expect(insertBeforeSpy).not.toHaveBeenCalled(); + }); + + it('should attempt to load Bugsnag SDK if not already loaded', done => { + loadBugsnagSDK(extSrcLoader, undefined); + + setTimeout(() => { + expect(insertBeforeSpy).toHaveBeenCalled(); + done(); + }, 500); + }); + + it('should invoke error handler and log error if Bugsnag SDK could not be loaded', done => { + timeouts.DEFAULT_EXT_SRC_LOAD_TIMEOUT_MS = 1000; // 1 second + bugsnagConstants.BUGSNAG_CDN_URL = 'https://asdf.com/bugsnag.min.js'; + loadBugsnagSDK(extSrcLoader, mockLogger); + + setTimeout(() => { + expect(mockErrorHandler.onError).toHaveBeenCalledWith( + new Error( + `Failed to load the script with the id "rs-bugsnag" from URL "https://asdf.com/bugsnag.min.js".`, + ), + 'ExternalSrcLoader', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + `BugsnagPlugin:: Failed to load the Bugsnag SDK.`, + ); + done(); + }, 2000); + }); + }); + + describe('onError', () => { + it('should return a function', () => { + expect(typeof onError(state)).toBe('function'); + }); + + it('should return a function that returns false if the error is not from RudderStack SDK', () => { + const error = { + stacktrace: [ + { + file: 'https://invalid-domain.com/not-rsa.min.js', + }, + ], + }; + + const onErrorFn = onError(state); + + expect(onErrorFn(error)).toBe(false); + }); + + it('should return a function that returns true and enhances the error event if the error is from RudderStack SDK', () => { + const error = { + stacktrace: [ + { + file: 'https://invalid-domain.com/rsa.min.js', + }, + ], + errorMessage: 'error in script loading "https://invalid-domain.com/rsa.min.js"', + updateMetaData: jest.fn(), + } as any; + + const onErrorFn = onError(state); + + expect(onErrorFn(error)).toBe(true); + expect(error.updateMetaData).toHaveBeenCalledTimes(2); + expect(error.updateMetaData).toHaveBeenNthCalledWith(1, 'source', { + snippetVersion: '3.0.0', + }); + expect(error.updateMetaData).toHaveBeenNthCalledWith(2, 'state', { + source: { + id: 'dummy-source-id', + }, + lifecycle: { + writeKey: 'dummy-write-key', + }, + context: { + app: { + name: 'test-app', + namespace: 'test-namespace', + version: '1.0.0', + installType: 'npm', + }, + }, + }); + expect(error.severity).toBe('error'); + expect(error.context).toBe('Script load failures'); + }); + + it('should return a function that returns false if processing the event results in any unhandled exception', () => { + // Not defining `updateMetaData` on the error object to simulate an unhandled exception + const error = { + stacktrace: [ + { + file: 'https://invalid-domain.com/rsa.min.js', + }, + ], + errorMessage: 'error in script loading "https://invalid-domain.com/rsa.min.js"', + } as any; + + const onErrorFn = onError(state); + + expect(onErrorFn(error)).toBe(false); + }); + + it('should log error and return false if the error could not be filtered', () => { + const mockLogger = new MockLogger(); + + const error = { + stacktrace: [ + { + file: 'https://invalid-domain.com/rsa.min.js', + }, + ], + errorMessage: 'error in script loading "https://invalid-domain.com/rsa.min.js"', + + // Simulate an unhandled exception + updateMetaData: jest.fn(() => { + throw new Error('Failed to update metadata.'); + }), + } as any; + + const onErrorFn = onError(state, mockLogger); + + expect(onErrorFn(error)).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith('BugsnagPlugin:: Failed to filter the error.'); + }); + }); + + describe('getAppStateForMetadata', () => { + const origAppStateExcludes = bugsnagConstants.APP_STATE_EXCLUDE_KEYS; + + beforeEach(() => { + bugsnagConstants.APP_STATE_EXCLUDE_KEYS = origAppStateExcludes; + }); + + // Here we are just exploring different combinations of data where + // the signals could be buried inside objects, arrays, nested objects, etc. + const tcData = [ + [ + { + name: 'test', + value: 123, + someKey1: [1, 2, 3], + someKey2: { + key1: 'value1', + key2: 'value2', + }, + someKey3: 2.5, + testSignal: signal('test'), + }, + { + name: 'test', + value: 123, + someKey1: [1, 2, 3], + someKey2: { + key1: 'value1', + key2: 'value2', + }, + someKey3: 2.5, + testSignal: 'test', + }, + undefined, + ], + [ + { + name: 'test', + someKey: { + key1: 'value1', + key2: signal('value2'), + }, + someKey2: { + key1: 'value1', + key2: { + key3: signal('value3'), + }, + }, + someKey3: [signal('value1'), signal('value2'), 1, 3], + someKey4: [ + { + key1: signal('value1'), + key2: signal('value2'), + }, + 'asdf', + 1, + { + key3: 'value3', + key4: 'value4', + }, + ], + }, + { + name: 'test', + someKey: { + key1: 'value1', + key2: 'value2', + }, + someKey2: { + key1: 'value1', + key2: { + key3: 'value3', + }, + }, + someKey3: ['value1', 'value2', 1, 3], + someKey4: [ + { + key1: 'value1', + key2: 'value2', + }, + 'asdf', + 1, + { + key3: 'value3', + key4: 'value4', + }, + ], + }, + [], + ], + [ + { + someKey: signal({ + key1: 'value1', + key2: signal('value2'), + key3: [signal('value1'), signal('value2'), undefined, null], + key4: true, + key7: { + key1: signal('value1'), + key2: signal('value2'), + key3: 'asdf', + key4: signal('value4'), + }, + key5: signal([signal('value1'), signal('value2'), 1, 3]), + KEY6: 123, + }), + }, + { + someKey: { + key1: 'value1', + key2: 'value2', + key3: ['value1', 'value2', null, null], + key7: { + key1: 'value1', + key2: 'value2', + key3: 'asdf', + }, + key5: ['value1', 'value2', 1, 3], + KEY6: 123, + }, + }, + ['key4', 'key6'], // excluded keys + ], + [ + { + someKey: BigInt(123), + }, + undefined, + [], + ], + ]; + + it.each(tcData)('should convert signals to JSON %#', (input, expected, excludes) => { + bugsnagConstants.APP_STATE_EXCLUDE_KEYS = excludes; + expect(getAppStateForMetadata(input)).toEqual(expected); + }); + }); +}); diff --git a/packages/analytics-js-plugins/__tests__/errorReporting/index.test.ts b/packages/analytics-js-plugins/__tests__/errorReporting/index.test.ts index 878c9d03a3..08f5dcd70e 100644 --- a/packages/analytics-js-plugins/__tests__/errorReporting/index.test.ts +++ b/packages/analytics-js-plugins/__tests__/errorReporting/index.test.ts @@ -28,6 +28,22 @@ describe('Plugin - ErrorReporting', () => { let state: any; + // Deprecated code + const mockPluginEngine = { + invokeSingle: jest.fn(() => Promise.resolve()), + }; + const mockExtSrcLoader = { + loadJSFile: jest.fn(() => Promise.resolve()), + }; + const mockLogger = { + error: jest.fn(), + }; + const mockErrReportingProviderClient = { + notify: jest.fn(), + leaveBreadcrumb: jest.fn(), + }; + // End of deprecated code + beforeEach(() => { state = clone(originalState); }); diff --git a/packages/analytics-js-plugins/package.json b/packages/analytics-js-plugins/package.json index 3035f1c89b..7aa8df02b7 100644 --- a/packages/analytics-js-plugins/package.json +++ b/packages/analytics-js-plugins/package.json @@ -92,7 +92,9 @@ "@rudderstack/analytics-js-common": "*", "ramda": "0.30.1" }, - "devDependencies": {}, + "devDependencies": { + "@bugsnag/js": "6.5.2" + }, "overrides": {}, "browserslist": { "production": [ diff --git a/packages/analytics-js-plugins/rollup.config.mjs b/packages/analytics-js-plugins/rollup.config.mjs index 576f6aad33..c32b748f1f 100644 --- a/packages/analytics-js-plugins/rollup.config.mjs +++ b/packages/analytics-js-plugins/rollup.config.mjs @@ -37,6 +37,7 @@ const isNpmPackageBuild = moduleType === 'npm'; const isCDNPackageBuild = moduleType === 'cdn'; const pluginsMap = { './BeaconQueue': './src/beaconQueue/index.ts', + './Bugsnag': './src/bugsnag/index.ts', './CustomConsentManager': './src/customConsentManager/index.ts', './DeviceModeDestinations': './src/deviceModeDestinations/index.ts', './DeviceModeTransformation': './src/deviceModeTransformation/index.ts', @@ -52,6 +53,8 @@ const pluginsMap = { './XhrQueue': './src/xhrQueue/index.ts', }; +const bugsnagSDKUrl = 'https://d2wy8f7a9ursnm.cloudfront.net/v6/bugsnag.min.js'; + export function getDefaultConfig(distName) { const version = process.env.VERSION || 'dev-snapshot'; const isLocalServerEnabled = isCDNPackageBuild && process.env.DEV_SERVER; @@ -76,7 +79,9 @@ export function getDefaultConfig(distName) { __PACKAGE_VERSION__: version, __MODULE_TYPE__: moduleType, __BUNDLE_ALL_PLUGINS__: isLegacyBuild, + __RS_BUGSNAG_API_KEY__: process.env.BUGSNAG_API_KEY || '{{__RS_BUGSNAG_API_KEY__}}', __RS_BUGSNAG_RELEASE_STAGE__: process.env.BUGSNAG_RELEASE_STAGE || 'production', + __RS_BUGSNAG_SDK_URL__: bugsnagSDKUrl, }), resolve({ jsnext: true, diff --git a/packages/analytics-js-plugins/src/bugsnag/constants.ts b/packages/analytics-js-plugins/src/bugsnag/constants.ts new file mode 100644 index 0000000000..931af3da70 --- /dev/null +++ b/packages/analytics-js-plugins/src/bugsnag/constants.ts @@ -0,0 +1,52 @@ +const BUGSNAG_LIB_INSTANCE_GLOBAL_KEY_NAME = 'bugsnag'; // For version 6 and below +const BUGSNAG_LIB_V7_INSTANCE_GLOBAL_KEY_NAME = 'Bugsnag'; +const GLOBAL_LIBRARY_OBJECT_NAMES = [ + BUGSNAG_LIB_V7_INSTANCE_GLOBAL_KEY_NAME, + BUGSNAG_LIB_INSTANCE_GLOBAL_KEY_NAME, +]; +const BUGSNAG_CDN_URL = '__RS_BUGSNAG_SDK_URL__'; +const ERROR_REPORT_PROVIDER_NAME_BUGSNAG = 'rs-bugsnag'; +// This API key token is parsed in the CI pipeline +const API_KEY = '__RS_BUGSNAG_API_KEY__'; +const BUGSNAG_VALID_MAJOR_VERSION = '6'; +const SDK_LOAD_POLL_INTERVAL_MS = 100; // ms +const MAX_WAIT_FOR_SDK_LOAD_MS = 100 * SDK_LOAD_POLL_INTERVAL_MS; // ms + +// Errors from the below scripts are NOT allowed to reach Bugsnag +const SDK_FILE_NAME_PREFIXES = (): string[] => [ + 'rsa', // Prefix for all the SDK scripts including plugins and module federated chunks +]; + +const DEV_HOSTS = ['www.test-host.com', 'localhost', '127.0.0.1', '[::1]']; + +// List of keys to exclude from the metadata +// Potential PII or sensitive data +const APP_STATE_EXCLUDE_KEYS = [ + 'userId', + 'userTraits', + 'groupId', + 'groupTraits', + 'anonymousId', + 'config', + 'instance', // destination instance objects + 'eventBuffer', // pre-load event buffer (may contain PII) + 'traits', +]; + +const BUGSNAG_PLUGIN = 'BugsnagPlugin'; + +export { + BUGSNAG_LIB_INSTANCE_GLOBAL_KEY_NAME, + BUGSNAG_LIB_V7_INSTANCE_GLOBAL_KEY_NAME, + GLOBAL_LIBRARY_OBJECT_NAMES, + BUGSNAG_CDN_URL, + ERROR_REPORT_PROVIDER_NAME_BUGSNAG, + API_KEY, + BUGSNAG_VALID_MAJOR_VERSION, + MAX_WAIT_FOR_SDK_LOAD_MS, + SDK_FILE_NAME_PREFIXES, + SDK_LOAD_POLL_INTERVAL_MS, + DEV_HOSTS, + APP_STATE_EXCLUDE_KEYS, + BUGSNAG_PLUGIN, +}; diff --git a/packages/analytics-js-plugins/src/bugsnag/index.ts b/packages/analytics-js-plugins/src/bugsnag/index.ts new file mode 100644 index 0000000000..45d16c6f4f --- /dev/null +++ b/packages/analytics-js-plugins/src/bugsnag/index.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-param-reassign */ +import type { ApplicationState } from '@rudderstack/analytics-js-common/types/ApplicationState'; +import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; +import type { IExternalSrcLoader } from '@rudderstack/analytics-js-common/services/ExternalSrcLoader/types'; +import type { ExtensionPlugin } from '@rudderstack/analytics-js-common/types/PluginEngine'; +import type { PluginName } from '@rudderstack/analytics-js-common/types/PluginsManager'; +import type { BugsnagLib } from '../types/plugins'; +import { BUGSNAG_API_KEY_VALIDATION_ERROR, BUGSNAG_SDK_URL_ERROR } from './logMessages'; +import { API_KEY } from './constants'; +import { initBugsnagClient, loadBugsnagSDK, isApiKeyValid } from './utils'; + +const pluginName: PluginName = 'Bugsnag'; + +const Bugsnag = (): ExtensionPlugin => ({ + name: pluginName, + deps: [], + initialize: (state: ApplicationState) => { + state.plugins.loadedPlugins.value = [...state.plugins.loadedPlugins.value, pluginName]; + }, + errorReportingProvider: { + init: ( + state: ApplicationState, + externalSrcLoader: IExternalSrcLoader, + logger?: ILogger, + ): Promise => + new Promise((resolve, reject) => { + // If API key token is not parsed or invalid, don't proceed to initialize the client + if (!isApiKeyValid(API_KEY)) { + reject(new Error(BUGSNAG_API_KEY_VALIDATION_ERROR(API_KEY))); + return; + } + + // If SDK URL is empty, don't proceed to initialize the client + // eslint-disable-next-line no-constant-condition + if (!'__RS_BUGSNAG_SDK_URL__') { + reject(new Error(BUGSNAG_SDK_URL_ERROR)); + return; + } + + loadBugsnagSDK(externalSrcLoader, logger); + + initBugsnagClient(state, resolve, reject, logger); + }), + notify: ( + client: BugsnagLib.Client, + error: Error, + state: ApplicationState, + logger?: ILogger, + ): void => { + client.notify(error); + }, + breadcrumb: (client: BugsnagLib.Client, message: string, logger?: ILogger): void => { + client?.leaveBreadcrumb(message); + }, + }, +}); + +export { Bugsnag }; + +export default Bugsnag; diff --git a/packages/analytics-js-plugins/src/bugsnag/logMessages.ts b/packages/analytics-js-plugins/src/bugsnag/logMessages.ts new file mode 100644 index 0000000000..bc028a6d0e --- /dev/null +++ b/packages/analytics-js-plugins/src/bugsnag/logMessages.ts @@ -0,0 +1,23 @@ +import { LOG_CONTEXT_SEPARATOR } from '@rudderstack/analytics-js-common/constants/logMessages'; + +const BUGSNAG_API_KEY_VALIDATION_ERROR = (apiKey: string): string => + `The Bugsnag API key (${apiKey}) is invalid or not provided.`; + +const BUGSNAG_SDK_LOAD_TIMEOUT_ERROR = (timeout: number): string => + `A timeout ${timeout} ms occurred while trying to load the Bugsnag SDK.`; + +const BUGSNAG_SDK_LOAD_ERROR = (context: string): string => + `${context}${LOG_CONTEXT_SEPARATOR}Failed to load the Bugsnag SDK.`; + +const BUGSNAG_SDK_URL_ERROR = 'The Bugsnag SDK URL is invalid. Failed to load the Bugsnag SDK.'; + +const FAILED_TO_FILTER_ERROR = (context: string): string => + `${context}${LOG_CONTEXT_SEPARATOR}Failed to filter the error.`; + +export { + BUGSNAG_API_KEY_VALIDATION_ERROR, + BUGSNAG_SDK_LOAD_TIMEOUT_ERROR, + BUGSNAG_SDK_LOAD_ERROR, + BUGSNAG_SDK_URL_ERROR, + FAILED_TO_FILTER_ERROR, +}; diff --git a/packages/analytics-js-plugins/src/bugsnag/utils.ts b/packages/analytics-js-plugins/src/bugsnag/utils.ts new file mode 100644 index 0000000000..2dfa8b8aff --- /dev/null +++ b/packages/analytics-js-plugins/src/bugsnag/utils.ts @@ -0,0 +1,217 @@ +import type { ApplicationState } from '@rudderstack/analytics-js-common/types/ApplicationState'; +import type { IExternalSrcLoader } from '@rudderstack/analytics-js-common/services/ExternalSrcLoader/types'; +import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; +import { CDN_INT_DIR } from '@rudderstack/analytics-js-common/constants/urls'; +import { json } from '../shared-chunks/common'; +import type { BugsnagLib } from '../types/plugins'; +import { + BUGSNAG_SDK_LOAD_ERROR, + BUGSNAG_SDK_LOAD_TIMEOUT_ERROR, + FAILED_TO_FILTER_ERROR, +} from './logMessages'; +import { + API_KEY, + APP_STATE_EXCLUDE_KEYS, + BUGSNAG_CDN_URL, + BUGSNAG_LIB_INSTANCE_GLOBAL_KEY_NAME, + BUGSNAG_PLUGIN, + BUGSNAG_VALID_MAJOR_VERSION, + DEV_HOSTS, + ERROR_REPORT_PROVIDER_NAME_BUGSNAG, + GLOBAL_LIBRARY_OBJECT_NAMES, + MAX_WAIT_FOR_SDK_LOAD_MS, + SDK_FILE_NAME_PREFIXES, + SDK_LOAD_POLL_INTERVAL_MS, +} from './constants'; + +const isValidVersion = (globalLibInstance: any) => { + // For version 7 + // eslint-disable-next-line no-underscore-dangle + let version = globalLibInstance?._client?._notifier?.version; + + // For versions older than 7 + if (!version) { + const tempInstance = globalLibInstance({ + apiKey: API_KEY, + releaseStage: 'version-test', + // eslint-disable-next-line func-names, object-shorthand + beforeSend: function () { + return false; + }, + }); + version = tempInstance.notifier?.version; + } + + return version && version.charAt(0) === BUGSNAG_VALID_MAJOR_VERSION; +}; + +const isRudderSDKError = (event: BugsnagLib.Report) => { + const errorOrigin = event.stacktrace?.[0]?.file; + + if (!errorOrigin || typeof errorOrigin !== 'string') { + return false; + } + + // Prefix folder for all the destination SDK scripts + const isDestinationIntegrationBundle = errorOrigin.includes(CDN_INT_DIR); + const srcFileName = errorOrigin.substring(errorOrigin.lastIndexOf('/') + 1); + + return ( + isDestinationIntegrationBundle || + SDK_FILE_NAME_PREFIXES().some( + prefix => srcFileName.startsWith(prefix) && srcFileName.endsWith('.js'), + ) + ); +}; + +const getAppStateForMetadata = (state: ApplicationState): Record | undefined => { + const stateStr = json.stringifyWithoutCircular(state, false, APP_STATE_EXCLUDE_KEYS); + return stateStr !== null ? JSON.parse(stateStr) : undefined; +}; + +const enhanceErrorEventMutator = (state: ApplicationState, event: BugsnagLib.Report): void => { + event.updateMetaData('source', { + snippetVersion: (globalThis as typeof window).RudderSnippetVersion, + }); + event.updateMetaData('state', getAppStateForMetadata(state) ?? {}); + + const { errorMessage } = event; + // eslint-disable-next-line no-param-reassign + event.context = errorMessage; + + // Hack for easily grouping the script load errors + // on the dashboard + if (errorMessage.includes('error in script loading')) { + // eslint-disable-next-line no-param-reassign + event.context = 'Script load failures'; + } + + // eslint-disable-next-line no-param-reassign + event.severity = 'error'; +}; + +const onError = + (state: ApplicationState, logger?: ILogger): BugsnagLib.BeforeSend => + (event: BugsnagLib.Report): boolean => { + try { + // Discard the event if it's not originated at the SDK + if (!isRudderSDKError(event)) { + return false; + } + + enhanceErrorEventMutator(state, event); + + return true; + } catch { + logger?.error(FAILED_TO_FILTER_ERROR(BUGSNAG_PLUGIN)); + // Drop the error event if it couldn't be filtered as + // it is most likely a non-SDK error + return false; + } + }; + +const getReleaseStage = () => { + const host = globalThis.location.hostname; + return host && DEV_HOSTS.includes(host) ? 'development' : '__RS_BUGSNAG_RELEASE_STAGE__'; +}; + +const getGlobalBugsnagLibInstance = () => (globalThis as any)[BUGSNAG_LIB_INSTANCE_GLOBAL_KEY_NAME]; + +const getNewClient = (state: ApplicationState, logger?: ILogger): BugsnagLib.Client => { + const globalBugsnagLibInstance = getGlobalBugsnagLibInstance(); + + const clientConfig: BugsnagLib.IConfig = { + apiKey: API_KEY, + appVersion: state.context.app.value.version, + metaData: { + SDK: { + name: 'JS', + installType: state.context.app.value.installType, + }, + }, + beforeSend: onError(state, logger), + autoCaptureSessions: false, // auto capture sessions is disabled + collectUserIp: false, // collecting user's IP is disabled + // enabledBreadcrumbTypes: ['error', 'log', 'user'], // for v7 and above + maxEvents: 100, + maxBreadcrumbs: 40, + releaseStage: getReleaseStage(), + user: { + id: state.source.value?.id || state.lifecycle.writeKey.value, + }, + logger, + networkBreadcrumbsEnabled: false, + }; + + const client: BugsnagLib.Client = globalBugsnagLibInstance(clientConfig); + + return client; +}; + +const isApiKeyValid = (apiKey: string): boolean => { + const isAPIKeyValid = !(apiKey.startsWith('{{') || apiKey.endsWith('}}') || apiKey.length === 0); + return isAPIKeyValid; +}; + +const loadBugsnagSDK = (externalSrcLoader: IExternalSrcLoader, logger?: ILogger) => { + const isNotLoaded = GLOBAL_LIBRARY_OBJECT_NAMES.every( + globalKeyName => !(globalThis as any)[globalKeyName], + ); + + if (!isNotLoaded) { + return; + } + + externalSrcLoader.loadJSFile({ + url: BUGSNAG_CDN_URL, + id: ERROR_REPORT_PROVIDER_NAME_BUGSNAG, + callback: id => { + if (!id) { + logger?.error(BUGSNAG_SDK_LOAD_ERROR(BUGSNAG_PLUGIN)); + } + }, + }); +}; + +const initBugsnagClient = ( + state: ApplicationState, + promiseResolve: (value: BugsnagLib.Client) => void, + promiseReject: (reason?: Error) => void, + logger?: ILogger, + time = 0, +): void => { + const globalBugsnagLibInstance = getGlobalBugsnagLibInstance(); + if (typeof globalBugsnagLibInstance === 'function') { + if (isValidVersion(globalBugsnagLibInstance)) { + const client = getNewClient(state, logger); + promiseResolve(client); + } + } else if (time >= MAX_WAIT_FOR_SDK_LOAD_MS) { + promiseReject(new Error(BUGSNAG_SDK_LOAD_TIMEOUT_ERROR(MAX_WAIT_FOR_SDK_LOAD_MS))); + } else { + // Try to initialize the client after a delay + (globalThis as typeof window).setTimeout( + initBugsnagClient, + SDK_LOAD_POLL_INTERVAL_MS, + state, + promiseResolve, + promiseReject, + logger, + time + SDK_LOAD_POLL_INTERVAL_MS, + ); + } +}; + +export { + isValidVersion, + getNewClient, + isApiKeyValid, + loadBugsnagSDK, + getGlobalBugsnagLibInstance, + initBugsnagClient, + getReleaseStage, + isRudderSDKError, + enhanceErrorEventMutator, + onError, + getAppStateForMetadata, +}; diff --git a/packages/analytics-js-plugins/src/errorReporting/index.ts b/packages/analytics-js-plugins/src/errorReporting/index.ts index 0b63bcb0d6..2eccaba6ae 100644 --- a/packages/analytics-js-plugins/src/errorReporting/index.ts +++ b/packages/analytics-js-plugins/src/errorReporting/index.ts @@ -22,6 +22,7 @@ import { } from './utils'; import { REQUEST_TIMEOUT_MS } from './constants'; import { ErrorFormat } from './event/event'; +import { INVALID_SOURCE_CONFIG_ERROR } from './logMessages'; const pluginName: PluginName = 'ErrorReporting'; @@ -31,17 +32,33 @@ const ErrorReporting = (): ExtensionPlugin => ({ initialize: (state: ApplicationState) => { state.plugins.loadedPlugins.value = [...state.plugins.loadedPlugins.value, pluginName]; state.reporting.isErrorReportingPluginLoaded.value = true; - state.reporting.breadcrumbs.value = [createNewBreadcrumb('Error Reporting Plugin Loaded')]; + if (state.reporting.breadcrumbs?.value) { + state.reporting.breadcrumbs.value = [createNewBreadcrumb('Error Reporting Plugin Loaded')]; + } }, errorReporting: { + // This extension point is deprecated + // TODO: Remove this in the next major release init: ( state: ApplicationState, pluginEngine: IPluginEngine, externalSrcLoader: IExternalSrcLoader, logger?: ILogger, + flag?: boolean, ) => { - // This extension point is deprecated - // TODO: Remove this in the next major release + if (flag) { + return undefined; + } + if (!state.source.value?.config || !state.source.value?.id) { + return Promise.reject(new Error(INVALID_SOURCE_CONFIG_ERROR)); + } + + return pluginEngine.invokeSingle( + 'errorReportingProvider.init', + state, + externalSrcLoader, + logger, + ); }, notify: ( pluginEngine: IPluginEngine, // Only kept for backward compatibility @@ -52,42 +69,46 @@ const ErrorReporting = (): ExtensionPlugin => ({ httpClient?: IHttpClient, errorState?: ErrorState, ): void => { - const { component, tolerateNonErrors, errorFramesToSkip, normalizedError } = - getConfigForPayloadCreation(error, errorState?.severityReason.type as string); + if (httpClient) { + const { component, tolerateNonErrors, errorFramesToSkip, normalizedError } = + getConfigForPayloadCreation(error, errorState?.severityReason.type as string); - // Generate the error payload - const errorPayload = ErrorFormat.create( - normalizedError, - tolerateNonErrors, - errorState as ErrorState, - component, - errorFramesToSkip, - logger, - ); + // Generate the error payload + const errorPayload = ErrorFormat.create( + normalizedError, + tolerateNonErrors, + errorState as ErrorState, + component, + errorFramesToSkip, + logger, + ); - // filter errors - if (!isRudderSDKError(errorPayload.errors[0])) { - return; - } + // filter errors + if (!isRudderSDKError(errorPayload.errors[0])) { + return; + } - // enrich error payload - const bugsnagPayload = getBugsnagErrorEvent(errorPayload, errorState as ErrorState, state); + // enrich error payload + const bugsnagPayload = getBugsnagErrorEvent(errorPayload, errorState as ErrorState, state); - // send it to metrics service - httpClient?.getAsyncData({ - url: `https://sdk-metrics.rudderstack.com/sdkmetrics`, - // url: `${state.lifecycle.dataPlaneUrl.value}/sdk-metrics`, - options: { - method: 'POST', - data: getErrorDeliveryPayload(bugsnagPayload, state), - sendRawData: true, - }, - isRawResponse: true, - timeout: REQUEST_TIMEOUT_MS, - callback: (result: any, details: any) => { - // do nothing - }, - }); + // send it to metrics service + httpClient?.getAsyncData({ + url: `https://sdk-metrics.rudderstack.com/sdkmetrics`, + // url: `${state.lifecycle.dataPlaneUrl.value}/sdk-metrics`, + options: { + method: 'POST', + data: getErrorDeliveryPayload(bugsnagPayload, state), + sendRawData: true, + }, + isRawResponse: true, + timeout: REQUEST_TIMEOUT_MS, + callback: (result: any, details: any) => { + // do nothing + }, + }); + } else { + pluginEngine.invokeSingle('errorReportingProvider.notify', client, error, state, logger); + } }, breadcrumb: ( pluginEngine: IPluginEngine, // Only kept for backward compatibility @@ -105,6 +126,8 @@ const ErrorReporting = (): ExtensionPlugin => ({ ...state.reporting.breadcrumbs.value, createNewBreadcrumb(message, metaData), ]; + } else { + pluginEngine.invokeSingle('errorReportingProvider.breadcrumb', client, message, logger); } }, }, diff --git a/packages/analytics-js-plugins/src/index.ts b/packages/analytics-js-plugins/src/index.ts index 82ffa70013..c898c0b79b 100644 --- a/packages/analytics-js-plugins/src/index.ts +++ b/packages/analytics-js-plugins/src/index.ts @@ -1,4 +1,5 @@ export { default as BeaconQueue } from './beaconQueue'; +export { default as Bugsnag } from './bugsnag'; export { default as CustomConsentManager } from './customConsentManager'; export { default as DeviceModeDestinations } from './deviceModeDestinations'; export { default as DeviceModeTransformation } from './deviceModeTransformation'; diff --git a/packages/analytics-js-plugins/src/types/plugins.ts b/packages/analytics-js-plugins/src/types/plugins.ts index 3964094464..251048406f 100644 --- a/packages/analytics-js-plugins/src/types/plugins.ts +++ b/packages/analytics-js-plugins/src/types/plugins.ts @@ -1,6 +1,8 @@ import type { IStore, IStoreManager } from '@rudderstack/analytics-js-common/types/Store'; import type { Nullable } from '@rudderstack/analytics-js-common/types/Nullable'; +export type { Bugsnag as BugsnagLib } from '@bugsnag/js'; + export type RudderEventType = 'page' | 'track' | 'identify' | 'alias' | 'group'; export type LogLevel = 'LOG' | 'INFO' | 'DEBUG' | 'WARN' | 'ERROR' | 'NONE'; diff --git a/packages/analytics-js/.size-limit.mjs b/packages/analytics-js/.size-limit.mjs index 2f0dce5d5a..b8c9904305 100644 --- a/packages/analytics-js/.size-limit.mjs +++ b/packages/analytics-js/.size-limit.mjs @@ -21,7 +21,7 @@ export default [ { name: 'Core Legacy - CDN', path: 'dist/cdn/legacy/iife/rsa.min.js', - limit: '47 KiB', + limit: '47.5 KiB', }, { name: 'Core - CDN', diff --git a/packages/analytics-js/__tests__/components/pluginsManager/PluginsManager.test.ts b/packages/analytics-js/__tests__/components/pluginsManager/PluginsManager.test.ts index 80e65609d8..d39502057f 100644 --- a/packages/analytics-js/__tests__/components/pluginsManager/PluginsManager.test.ts +++ b/packages/analytics-js/__tests__/components/pluginsManager/PluginsManager.test.ts @@ -64,7 +64,7 @@ describe('PluginsManager', () => { state.reporting.isErrorReportingEnabled.value = true; expect(pluginsManager.getPluginsToLoadBasedOnConfig().sort()).toEqual( - ['ErrorReporting', 'ExternalAnonymousId', 'GoogleLinker'].sort(), + ['ErrorReporting', 'Bugsnag', 'ExternalAnonymousId', 'GoogleLinker'].sort(), ); }); @@ -77,7 +77,7 @@ describe('PluginsManager', () => { // Expect a warning for user not explicitly configuring it expect(defaultLogger.warn).toHaveBeenCalledTimes(1); expect(defaultLogger.warn).toHaveBeenCalledWith( - "PluginsManager:: Error reporting is enabled, but 'ErrorReporting' plugin was not configured to load. Ignore if this was intentional. Otherwise, consider adding it to the 'plugins' load API option.", + "PluginsManager:: Error reporting is enabled, but ['ErrorReporting', 'Bugsnag'] plugins were not configured to load. Ignore if this was intentional. Otherwise, consider adding them to the 'plugins' load API option.", ); }); diff --git a/packages/analytics-js/rollup.config.mjs b/packages/analytics-js/rollup.config.mjs index 954f74f902..b28dcd70be 100644 --- a/packages/analytics-js/rollup.config.mjs +++ b/packages/analytics-js/rollup.config.mjs @@ -44,11 +44,13 @@ const remotePluginsExportsFilename = `rsa-plugins`; const remotePluginsHostPromise = `Promise.resolve(window.RudderStackGlobals && window.RudderStackGlobals.app && window.RudderStackGlobals.app.pluginsCDNPath ? \`\${window.RudderStackGlobals.app.pluginsCDNPath}/${remotePluginsExportsFilename}.js\` : \`${remotePluginsBasePath}/${remotePluginsExportsFilename}.js\`)`; const moduleType = process.env.MODULE_TYPE || 'cdn'; const isCDNPackageBuild = moduleType === 'cdn'; +let bugsnagSDKUrl = 'https://d2wy8f7a9ursnm.cloudfront.net/v6/bugsnag.min.js'; let polyfillIoUrl = 'https://polyfill-fastly.io/v3/polyfill.min.js'; // For Chrome extension as content script any references in code to third party URLs // throw violations at approval phase even if relevant code is not used if (isContentScriptBuild) { + bugsnagSDKUrl = ''; polyfillIoUrl = ''; } @@ -79,6 +81,10 @@ const getExternalsConfig = () => { externalGlobalsConfig['@rudderstack/analytics-js-plugins/beaconQueue'] = '{}'; } + if (!bundledPluginsList.includes('Bugsnag')) { + externalGlobalsConfig['@rudderstack/analytics-js-plugins/bugsnag'] = '{}'; + } + if (!bundledPluginsList.includes('CustomConsentManager')) { externalGlobalsConfig['@rudderstack/analytics-js-plugins/customConsentManager'] = '{}'; } @@ -182,6 +188,9 @@ export function getDefaultConfig(distName) { __MODULE_TYPE__: moduleType, __SDK_BUNDLE_FILENAME__: distName, __RS_POLYFILLIO_SDK_URL__: polyfillIoUrl, + __RS_BUGSNAG_API_KEY__: process.env.BUGSNAG_API_KEY || '{{__RS_BUGSNAG_API_KEY__}}', + __RS_BUGSNAG_RELEASE_STAGE__: process.env.BUGSNAG_RELEASE_STAGE || 'production', + __RS_BUGSNAG_SDK_URL__: bugsnagSDKUrl, }), resolve({ jsnext: true, diff --git a/packages/analytics-js/src/components/configManager/util/commonUtil.ts b/packages/analytics-js/src/components/configManager/util/commonUtil.ts index c778f87dcd..ac7e15ad6e 100644 --- a/packages/analytics-js/src/components/configManager/util/commonUtil.ts +++ b/packages/analytics-js/src/components/configManager/util/commonUtil.ts @@ -2,6 +2,7 @@ import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; import { CONFIG_MANAGER } from '@rudderstack/analytics-js-common/constants/loggerContexts'; import { batch } from '@preact/signals-core'; import { isDefined, isUndefined } from '@rudderstack/analytics-js-common/utilities/checks'; +import { isSDKRunningInChromeExtension } from '@rudderstack/analytics-js-common/utilities/detect'; import { DEFAULT_STORAGE_TYPE } from '@rudderstack/analytics-js-common/types/Storage'; import type { DeliveryType, @@ -72,7 +73,8 @@ const getSDKUrl = (): string | undefined => { * @param logger Logger instance */ const updateReportingState = (res: SourceConfigResponse): void => { - state.reporting.isErrorReportingEnabled.value = isErrorReportingEnabled(res.source.config); + state.reporting.isErrorReportingEnabled.value = + isErrorReportingEnabled(res.source.config) && !isSDKRunningInChromeExtension(); state.reporting.isMetricsReportingEnabled.value = isMetricsReportingEnabled(res.source.config); }; diff --git a/packages/analytics-js/src/components/core/Analytics.ts b/packages/analytics-js/src/components/core/Analytics.ts index cbd850eaa5..b14797c10f 100644 --- a/packages/analytics-js/src/components/core/Analytics.ts +++ b/packages/analytics-js/src/components/core/Analytics.ts @@ -234,7 +234,6 @@ class Analytics implements IAnalytics { } prepareInternalServices() { - this.errorHandler.init(this.httpClient); this.pluginsManager = new PluginsManager(defaultPluginEngine, this.errorHandler, this.logger); this.storeManager = new StoreManager(this.pluginsManager, this.errorHandler, this.logger); this.configManager = new ConfigManager(this.httpClient, this.errorHandler, this.logger); @@ -274,6 +273,7 @@ class Analytics implements IAnalytics { * Initialize the storage and event queue */ onPluginsReady() { + this.errorHandler.init(this.httpClient, this.externalSrcLoader); // Initialize storage this.storeManager?.init(); this.userSessionManager?.init(); diff --git a/packages/analytics-js/src/components/pluginsManager/PluginsManager.ts b/packages/analytics-js/src/components/pluginsManager/PluginsManager.ts index ad53d42f8a..4501099fc6 100644 --- a/packages/analytics-js/src/components/pluginsManager/PluginsManager.ts +++ b/packages/analytics-js/src/components/pluginsManager/PluginsManager.ts @@ -13,10 +13,7 @@ import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; import type { Nullable } from '@rudderstack/analytics-js-common/types/Nullable'; import { PLUGINS_MANAGER } from '@rudderstack/analytics-js-common/constants/loggerContexts'; import { isDefined, isFunction } from '@rudderstack/analytics-js-common/utilities/checks'; -import { - generateMisconfiguredPluginsWarning, - DEPRECATED_PLUGIN_WARNING, -} from '../../constants/logMessages'; +import { generateMisconfiguredPluginsWarning } from '../../constants/logMessages'; import { setExposedGlobal } from '../utilities/globals'; import { state } from '../../state'; import { @@ -24,7 +21,7 @@ import { StorageEncryptionVersionsToPluginNameMap, DataPlaneEventsTransportToPluginNameMap, } from '../configManager/constants'; -import { deprecatedPluginsList, pluginNamesList } from './pluginNames'; +import { pluginNamesList } from './pluginNames'; import { getMandatoryPluginsMap, pluginsInventory, @@ -98,14 +95,15 @@ class PluginsManager implements IPluginsManager { return []; } + // TODO: Uncomment below lines after removing deprecated plugin // Filter deprecated plugins - pluginsToLoadFromConfig = pluginsToLoadFromConfig.filter(pluginName => { - if (deprecatedPluginsList.includes(pluginName)) { - this.logger?.warn(DEPRECATED_PLUGIN_WARNING(PLUGINS_MANAGER, pluginName)); - return false; - } - return true; - }); + // pluginsToLoadFromConfig = pluginsToLoadFromConfig.filter(pluginName => { + // if (deprecatedPluginsList.includes(pluginName)) { + // this.logger?.warn(DEPRECATED_PLUGIN_WARNING(PLUGINS_MANAGER, pluginName)); + // return false; + // } + // return true; + // }); const pluginGroupsToProcess: PluginsGroup[] = [ { @@ -118,7 +116,7 @@ class PluginsManager implements IPluginsManager { { configurationStatus: () => state.reporting.isErrorReportingEnabled.value, configurationStatusStr: 'Error reporting is enabled', - supportedPlugins: ['ErrorReporting'] as PluginName[], + supportedPlugins: ['ErrorReporting', 'Bugsnag'] as PluginName[], // TODO: Remove deprecated plugin- Bugsnag }, { configurationStatus: () => diff --git a/packages/analytics-js/src/components/pluginsManager/bundledBuildPluginImports.ts b/packages/analytics-js/src/components/pluginsManager/bundledBuildPluginImports.ts index ee419cfb95..8dd16fa8a4 100644 --- a/packages/analytics-js/src/components/pluginsManager/bundledBuildPluginImports.ts +++ b/packages/analytics-js/src/components/pluginsManager/bundledBuildPluginImports.ts @@ -1,4 +1,5 @@ import { BeaconQueue } from '@rudderstack/analytics-js-plugins/beaconQueue'; +import { Bugsnag } from '@rudderstack/analytics-js-plugins/bugsnag'; import { CustomConsentManager } from '@rudderstack/analytics-js-plugins/customConsentManager'; import { DeviceModeDestinations } from '@rudderstack/analytics-js-plugins/deviceModeDestinations'; import { DeviceModeTransformation } from '@rudderstack/analytics-js-plugins/deviceModeTransformation'; @@ -19,6 +20,7 @@ import type { PluginMap } from './types'; */ const getBundledBuildPluginImports = (): PluginMap => ({ BeaconQueue, + Bugsnag, CustomConsentManager, DeviceModeDestinations, DeviceModeTransformation, diff --git a/packages/analytics-js/src/components/pluginsManager/defaultPluginsList.ts b/packages/analytics-js/src/components/pluginsManager/defaultPluginsList.ts index bfc05f44ea..149f94278c 100644 --- a/packages/analytics-js/src/components/pluginsManager/defaultPluginsList.ts +++ b/packages/analytics-js/src/components/pluginsManager/defaultPluginsList.ts @@ -5,6 +5,7 @@ import type { PluginName } from '@rudderstack/analytics-js-common/types/PluginsM */ const defaultOptionalPluginsList: PluginName[] = [ 'BeaconQueue', + 'Bugsnag', 'CustomConsentManager', 'DeviceModeDestinations', 'DeviceModeTransformation', diff --git a/packages/analytics-js/src/components/pluginsManager/federatedModulesBuildPluginImports.ts b/packages/analytics-js/src/components/pluginsManager/federatedModulesBuildPluginImports.ts index e8874ebaa6..d5e8485ff1 100644 --- a/packages/analytics-js/src/components/pluginsManager/federatedModulesBuildPluginImports.ts +++ b/packages/analytics-js/src/components/pluginsManager/federatedModulesBuildPluginImports.ts @@ -12,6 +12,8 @@ const getFederatedModuleImport = ( switch (pluginName) { case 'BeaconQueue': return () => import('rudderAnalyticsRemotePlugins/BeaconQueue'); + case 'Bugsnag': + return () => import('rudderAnalyticsRemotePlugins/Bugsnag'); case 'CustomConsentManager': return () => import('rudderAnalyticsRemotePlugins/CustomConsentManager'); case 'DeviceModeDestinations': diff --git a/packages/analytics-js/src/components/pluginsManager/pluginNames.ts b/packages/analytics-js/src/components/pluginsManager/pluginNames.ts index c138b611eb..4cb8bb6143 100644 --- a/packages/analytics-js/src/components/pluginsManager/pluginNames.ts +++ b/packages/analytics-js/src/components/pluginsManager/pluginNames.ts @@ -10,6 +10,7 @@ const localPluginNames: PluginName[] = []; */ const pluginNamesList: PluginName[] = [ 'BeaconQueue', + 'Bugsnag', // deprecated 'CustomConsentManager', 'DeviceModeDestinations', 'DeviceModeTransformation', diff --git a/packages/analytics-js/src/services/ErrorHandler/ErrorHandler.ts b/packages/analytics-js/src/services/ErrorHandler/ErrorHandler.ts index 95cac92ff2..b1d0dfe270 100644 --- a/packages/analytics-js/src/services/ErrorHandler/ErrorHandler.ts +++ b/packages/analytics-js/src/services/ErrorHandler/ErrorHandler.ts @@ -13,7 +13,11 @@ import { ERROR_HANDLER } from '@rudderstack/analytics-js-common/constants/logger import { LOG_CONTEXT_SEPARATOR } from '@rudderstack/analytics-js-common/constants/logMessages'; import { BufferQueue } from '@rudderstack/analytics-js-common/services/BufferQueue/BufferQueue'; import type { IHttpClient } from '@rudderstack/analytics-js-common/types/HttpClient'; -import { NOTIFY_FAILURE_ERROR } from '../../constants/logMessages'; +import type { IExternalSrcLoader } from '@rudderstack/analytics-js-common/services/ExternalSrcLoader/types'; +import { + NOTIFY_FAILURE_ERROR, + REPORTING_PLUGIN_INIT_FAILURE_ERROR, +} from '../../constants/logMessages'; import { state } from '../../state'; import { defaultPluginEngine } from '../PluginEngine'; import { defaultLogger } from '../Logger'; @@ -72,8 +76,36 @@ class ErrorHandler implements IErrorHandler { } } - init(httpClient?: IHttpClient) { + init(httpClient: IHttpClient, externalSrcLoader: IExternalSrcLoader) { this.httpClient = httpClient; + // Below lines are only kept for backward compatibility + // TODO: Remove this in the next major release + if (!this.pluginEngine) { + return; + } + + try { + const extPoint = 'errorReporting.init'; + const errReportingInitVal = this.pluginEngine.invokeSingle( + extPoint, + state, + this.pluginEngine, + externalSrcLoader, + this.logger, + true, + ); + if (errReportingInitVal instanceof Promise) { + errReportingInitVal + .then((client: any) => { + this.errReportingClient = client; + }) + .catch(err => { + this.logger?.error(REPORTING_PLUGIN_INIT_FAILURE_ERROR(ERROR_HANDLER), err); + }); + } + } catch (err) { + this.onError(err, ERROR_HANDLER); + } } onError( @@ -156,7 +188,7 @@ class ErrorHandler implements IErrorHandler { this.pluginEngine.invokeSingle( 'errorReporting.breadcrumb', this.pluginEngine, // deprecated parameter - undefined, // deprecated parameter + this.errReportingClient, // deprecated parameter breadcrumb, this.logger, state, @@ -178,7 +210,7 @@ class ErrorHandler implements IErrorHandler { this.pluginEngine?.invokeSingle( 'errorReporting.notify', this.pluginEngine, // deprecated parameter - undefined, // deprecated parameter + this.errReportingClient, // deprecated parameter error, state, this.logger,