Skip to content

Commit

Permalink
feat: error reporting plugin (#1601)
Browse files Browse the repository at this point in the history
* feat: error reporting plugin

* chore: size limit updated

* chore: size limit updated

* chore: refactor onerror fn and minor changes

* chore: review comment addressed

* refactor: instead of bugsnag core pkg used only required part of event class

* chore: code refactoring

* chore: code refactoring

* chore: review commit addressed

* chore: review comment addressed

* chore: modified isruddersdkerror fn logic to filter integration sdk errors

* chore: fix plugin loading

* chore: log message updated

* chore: used template literal instead of string concatenation in rollup

* fix: unit test cases

* chore: unit test cases

* chore: lock file modified

* chore: more unit tests

* chore: ignore coverage for third party code

* chore: ignore coverage for third party code in sonar

* chore: test cases

* chore: ignore coverage for third party code in sonar

* chore: remove reference

* chore: revert formatting changes

* chore: address review comments

* chore: address review comments

* chore: updated plugin signature for backward compatibility

* chore: added bugsnag plugin for backward compatibility

* chore: size limit updated

* chore: review comment address

* chore: update metrics service url

* chore: address review comment

* chore: address review comment

* chore: address review comment

---------

Co-authored-by: George Bardis <[email protected]>
  • Loading branch information
MoumitaM and bardisg authored Jul 19, 2024
1 parent 5d750cf commit 1f2629e
Show file tree
Hide file tree
Showing 41 changed files with 1,789 additions and 479 deletions.
3 changes: 3 additions & 0 deletions jest/jest.setup-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ global.window.innerHeight = 1024;
global.window.__BUNDLE_ALL_PLUGINS__ = false;
global.window.__IS_LEGACY_BUILD__ = false;
global.window.__IS_DYNAMIC_CUSTOM_BUNDLE__ = false;
global.PromiseRejectionEvent = function (reason) {
this.reason = reason;
};

// TODO: remove once we use globalThis in analytics v1.1 too
// Setup mocking for window.navigator
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"crypto-es": "2.1.0",
"crypto-js": "4.2.0",
"deep-object-diff": "1.1.9",
"error-stack-parser": "2.1.4",
"get-value": "3.0.1",
"is": "3.3.0",
"join-component": "1.1.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/analytics-js-common/src/constants/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const METRICS_PAYLOAD_VERSION = '1';

export { METRICS_PAYLOAD_VERSION };
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ErrorState } from '@rudderstack/analytics-js-common/types/ErrorHandler';
import type { ILogger } from '../../types/Logger';

export interface IExternalSourceLoadConfig {
Expand All @@ -18,7 +19,7 @@ export interface IExternalSrcLoader {
shouldAlwaysThrow?: boolean,
): void;
leaveBreadcrumb(breadcrumb: string): void;
notifyError(error: Error): void;
notifyError(error: Error, errorState: ErrorState): void;
};
logger?: ILogger;
timeout: number;
Expand Down
14 changes: 13 additions & 1 deletion packages/analytics-js-common/src/types/ApplicationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export type MetricsState = {
sent: Signal<number>;
queued: Signal<number>;
triggered: Signal<number>;
metricsServiceUrl: Signal<string | undefined>;
};

export type DataPlaneEventsState = {
Expand Down Expand Up @@ -119,11 +120,22 @@ export type PluginsState = {
totalPluginsToLoad: Signal<number>;
};

export type BreadcrumbMetaData = {
[index: string]: any;
};
export type BreadcrumbType = 'error' | 'manual';
export type Breadcrumb = {
type: BreadcrumbType;
name: string;
timestamp: Date;
metaData: BreadcrumbMetaData;
};

export type ReportingState = {
isErrorReportingEnabled: Signal<boolean>;
isMetricsReportingEnabled: Signal<boolean>;
errorReportingProviderPluginName: Signal<PluginName | undefined>;
isErrorReportingPluginLoaded: Signal<boolean>;
breadcrumbs: Signal<Breadcrumb[]>;
};

export type SessionState = {
Expand Down
13 changes: 10 additions & 3 deletions packages/analytics-js-common/src/types/ErrorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { IPluginEngine } from './PluginEngine';
import type { ILogger } from './Logger';
import type { IExternalSrcLoader } from '../services/ExternalSrcLoader/types';
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;

export interface IErrorHandler {
logger?: ILogger;
pluginEngine?: IPluginEngine;
errorBuffer: BufferQueue<PreLoadErrorData>;
init(externalSrcLoader: IExternalSrcLoader): void;
init(httpClient: IHttpClient, externalSrcLoader: IExternalSrcLoader): void;
onError(
error: SDKError,
context?: string,
Expand All @@ -18,7 +19,7 @@ export interface IErrorHandler {
errorType?: string,
): void;
leaveBreadcrumb(breadcrumb: string): void;
notifyError(error: Error): void;
notifyError(error: Error, errorState: ErrorState): void;
attachErrorListeners(): void;
}

Expand All @@ -37,3 +38,9 @@ export type PreLoadErrorData = {
error: SDKError;
errorState: ErrorState;
};

export enum ErrorType {
HANDLEDEXCEPTION = 'handledException',
UNHANDLEDEXCEPTION = 'unhandledException',
UNHANDLEDREJECTION = 'unhandledPromiseRejection',
}
70 changes: 70 additions & 0 deletions packages/analytics-js-common/src/types/Metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Breadcrumb } from './ApplicationState';

export type MetricServicePayload = {
version: string;
message_id: string;
source: {
name: string;
sdk_version: string;
write_key: string;
install_type: string;
};
errors?: ErrorEventPayload;
};

export type ErrorEventPayload = {
notifier: {
name: string;
version: string;
url: string;
};
events: ErrorEventType[];
};

export type ErrorEventType = {
payloadVersion: string;
exceptions: Exception[];
severity: string;
unhandled: boolean;
severityReason: { type: string };
app: {
version: string;
releaseStage: string;
};
device: {
locale?: string;
userAgent?: string;
time?: Date;
};
request: {
url: string;
clientIp: string;
};
breadcrumbs: Breadcrumb[] | [];
context: string;
metaData: {
[index: string]: any;
};
user: {
id: string;
};
};

export type GeneratedEventType = {
errors: Exception[];
};

export interface Exception {
message: string;
errorClass: string;
type: string;
stacktrace: Stackframe[];
}
export interface Stackframe {
file: string;
method?: string;
lineNumber?: number;
columnNumber?: number;
code?: Record<string, string>;
inProject?: boolean;
}
4 changes: 2 additions & 2 deletions packages/analytics-js-plugins/.size-limit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default [
{
name: 'Plugins - Legacy - CDN',
path: 'dist/cdn/legacy/plugins/rsa-plugins-*.min.js',
limit: '15 KiB',
limit: '16 KiB',
},
{
name: 'Plugins Module Federation Mapping - Modern - CDN',
Expand All @@ -21,6 +21,6 @@ export default [
{
name: 'Plugins - Modern - CDN',
path: 'dist/cdn/modern/plugins/rsa-plugins-*.min.js',
limit: '6 KiB',
limit: '7.5 KiB',
},
];
116 changes: 71 additions & 45 deletions packages/analytics-js-plugins/__tests__/errorReporting/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { signal } from '@preact/signals-core';
import { clone } from 'ramda';
import type { IHttpClient } from '@rudderstack/analytics-js-common/types/HttpClient';
import { ErrorReporting } from '../../src/errorReporting';

describe('Plugin - ErrorReporting', () => {
Expand All @@ -10,6 +11,15 @@ describe('Plugin - ErrorReporting', () => {
lifecycle: {
writeKey: signal('dummy-write-key'),
},
reporting: {
isErrorReportingPluginLoaded: signal(false),
breadcrumbs: signal([]),
},
context: {
locale: signal('en-GB'),
userAgent: signal('sample user agent'),
app: signal({ version: 'sample_version', installType: 'sample_installType' }),
},
source: signal({
id: 'test-source-id',
config: {},
Expand All @@ -18,6 +28,7 @@ describe('Plugin - ErrorReporting', () => {

let state: any;

// Deprecated code
const mockPluginEngine = {
invokeSingle: jest.fn(() => Promise.resolve()),
};
Expand All @@ -31,6 +42,7 @@ describe('Plugin - ErrorReporting', () => {
notify: jest.fn(),
leaveBreadcrumb: jest.fn(),
};
// End of deprecated code

beforeEach(() => {
state = clone(originalState);
Expand All @@ -39,64 +51,78 @@ describe('Plugin - ErrorReporting', () => {
it('should add ErrorReporting plugin in the loaded plugin list', () => {
ErrorReporting().initialize(state);
expect(state.plugins.loadedPlugins.value.includes('ErrorReporting')).toBe(true);
expect(state.reporting.isErrorReportingPluginLoaded.value).toBe(true);
expect(state.reporting.breadcrumbs.value[0].name).toBe('Error Reporting Plugin Loaded');
});

it('should reject the promise if source information is not available', async () => {
state.source.value = null;

const pluginInitPromise = ErrorReporting().errorReporting.init(state);

await expect(pluginInitPromise).rejects.toThrow('Invalid source configuration or source id.');
});

it('should invoke the error reporting provider plugin on init', async () => {
const pluginInitPromise = ErrorReporting().errorReporting.init(
it('should invoke the error reporting provider plugin on notify', () => {
state.lifecycle = {
writeKey: signal('sample-write-key'),
};
state.metrics = {
metricsServiceUrl: signal('https://test.com'),
};
const mockHttpClient = {
getAsyncData: jest.fn(),
setAuthHeader: jest.fn(),
} as unknown as IHttpClient;
const newError = new Error();
const normalizedError = Object.create(newError, {
message: { value: 'ReferenceError: testUndefinedFn is not defined' },
stack: {
value: `ReferenceError: testUndefinedFn is not defined at Analytics.page (http://localhost:3001/cdn/modern/iife/rsa.js:1610:3) at RudderAnalytics.page (http://localhost:3001/cdn/modern/iife/rsa.js:1666:84)`,
},
});
ErrorReporting().errorReporting.notify(
{},
undefined,
normalizedError,
state,
mockPluginEngine,
mockExtSrcLoader,
mockLogger,
undefined,
mockHttpClient,
{
severity: 'error',
unhandled: false,
severityReason: { type: 'handledException' },
},
);

await expect(pluginInitPromise).resolves.toBeUndefined(); // because it's just a mock
expect(mockPluginEngine.invokeSingle).toHaveBeenCalledWith(
'errorReportingProvider.init',
state,
mockExtSrcLoader,
mockLogger,
);
expect(mockHttpClient.getAsyncData).toHaveBeenCalled();
});

it('should invoke the error reporting provider plugin on notify', () => {
it('should not notify if the error is not an SDK error', () => {
const mockHttpClient = {
getAsyncData: jest.fn(),
setAuthHeader: jest.fn(),
} as unknown as IHttpClient;
const newError = new Error();
const normalizedError = Object.create(newError, {
message: { value: 'ReferenceError: testUndefinedFn is not defined' },
stack: {
value: `ReferenceError: testUndefinedFn is not defined at Abcd.page (http://localhost:3001/cdn/modern/iife/abc.js:1610:3) at xyz.page (http://localhost:3001/cdn/modern/iife/abc.js:1666:84)`,
},
});
ErrorReporting().errorReporting.notify(
mockPluginEngine,
mockErrReportingProviderClient,
new Error('dummy error'),
{},
undefined,
normalizedError,
state,
mockLogger,
undefined,
mockHttpClient,
{
severity: 'error',
unhandled: false,
severityReason: { type: 'handledException' },
},
);

expect(mockPluginEngine.invokeSingle).toHaveBeenCalledWith(
'errorReportingProvider.notify',
mockErrReportingProviderClient,
new Error('dummy error'),
state,
mockLogger,
);
expect(mockHttpClient.getAsyncData).not.toHaveBeenCalled();
});

it('should invoke the error reporting provider plugin on breadcrumb', () => {
ErrorReporting().errorReporting.breadcrumb(
mockPluginEngine,
mockErrReportingProviderClient,
'dummy breadcrumb',
mockLogger,
);
it('should add a new breadcrumb', () => {
const breadcrumbLength = state.reporting.breadcrumbs.value.length;
ErrorReporting().errorReporting.breadcrumb({}, undefined, 'dummy breadcrumb', undefined, state);

expect(mockPluginEngine.invokeSingle).toHaveBeenCalledWith(
'errorReportingProvider.breadcrumb',
mockErrReportingProviderClient,
'dummy breadcrumb',
mockLogger,
);
expect(state.reporting.breadcrumbs.value.length).toBe(breadcrumbLength + 1);
});
});
Loading

0 comments on commit 1f2629e

Please sign in to comment.