diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts index c1cfbd7d3..daaf37564 100644 --- a/packages/analytics-browser/src/browser-client.ts +++ b/packages/analytics-browser/src/browser-client.ts @@ -98,6 +98,7 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient, An } return returnWrapper(this._init({ ...options, userId, apiKey })); } + protected async _init(options: BrowserOptions & { apiKey: string }) { // Step 1: Block concurrent initialization if (this.initializing) { diff --git a/packages/analytics-core/src/diagnostics/diagnostics-client.ts b/packages/analytics-core/src/diagnostics/diagnostics-client.ts index 8cce18ff8..4e64c4974 100644 --- a/packages/analytics-core/src/diagnostics/diagnostics-client.ts +++ b/packages/analytics-core/src/diagnostics/diagnostics-client.ts @@ -3,6 +3,7 @@ import { DiagnosticsStorage, IDiagnosticsStorage } from './diagnostics-storage'; import { ServerZoneType } from '../types/server-zone'; import { getGlobalScope } from '../global-scope'; import { isTimestampInSampleTemp } from '../utils/sampling'; +import { setupAmplitudeErrorTracking } from './diagnostics-uncaught-sdk-error-web-handlers'; export const SAVE_INTERVAL_MS = 1000; // 1 second export const FLUSH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -212,9 +213,12 @@ export class DiagnosticsClient implements IDiagnosticsClient { void this.initializeFlushInterval(); - // Track internal diagnostics metrics for sampling if (this.shouldTrack) { + // Track internal diagnostics metrics for sampling this.increment('sdk.diagnostics.sampled.in.and.enabled'); + + // Setup error tracking to capture uncaught SDK errors (web only) + setupAmplitudeErrorTracking(this); } } diff --git a/packages/analytics-core/src/diagnostics/diagnostics-uncaught-sdk-error-global-tracker.ts b/packages/analytics-core/src/diagnostics/diagnostics-uncaught-sdk-error-global-tracker.ts new file mode 100644 index 000000000..f5917429b --- /dev/null +++ b/packages/analytics-core/src/diagnostics/diagnostics-uncaught-sdk-error-global-tracker.ts @@ -0,0 +1,101 @@ +/** + * SDK Error Tracking via Error Tagging + * + * Identifies SDK-originated errors by tagging error objects thrown from SDK code. + * Uses a WeakSet to tag errors, enabling detection by global error handlers + * even after the original execution context has exited. + * + * Usage: + * - Wrap SDK functions/callbacks with `diagnosticsUncaughtError(fn)` + * - Global error handlers check `isPendingSDKError(error)` to identify SDK errors + */ + +// Track error objects that originated from SDK code +// Using WeakSet prevents memory leaks as errors are garbage collected +const pendingSDKErrors = new WeakSet(); + +/** + * Wraps a function to tag any thrown errors as SDK-originated. + * + * Use this to wrap SDK functions, methods, event listeners, or callbacks. + * When the wrapped function throws an error (sync or async), the error is + * tagged and re-thrown, allowing global error handlers to identify it as + * an SDK error. + * + * @param fn - The function to wrap + * @returns A wrapped function that tags errors before re-throwing + * + * @example + * ```typescript + * // Wrap a class method + * class AmplitudeBrowser { + * track = diagnosticsUncaughtError(async (event: Event) => { + * // ... SDK tracking code ... + * }); + * } + * + * // Wrap an event listener callback + * window.addEventListener('click', diagnosticsUncaughtError((event) => { + * // ... SDK code ... + * })); + * + * // Wrap an observable subscription + * observable.subscribe(diagnosticsUncaughtError((value) => { + * // ... SDK code ... + * })); + * ``` + */ +export function diagnosticsUncaughtError any>(fn: T): T { + return function (this: any, ...args: Parameters): ReturnType { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const result = fn.apply(this, args); + + // If async, attach error handler to tag async errors + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (result && typeof result === 'object' && typeof result.then === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + return result.catch((error: unknown) => { + if (error instanceof Error) { + pendingSDKErrors.add(error); + } + throw error; + }) as ReturnType; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return result; + } catch (error) { + // Tag synchronous errors as SDK-originated + if (error instanceof Error) { + pendingSDKErrors.add(error); + } + throw error; + } + } as T; +} + +/** + * Check if an error object was tagged as SDK-originated. + * This is useful for detecting SDK errors in global error handlers, + * even after the execution context has exited. + * + * @param error - The error to check + * @returns true if the error originated from SDK code + */ +export function isPendingSDKError(error: unknown): boolean { + return error instanceof Error && pendingSDKErrors.has(error); +} + +/** + * Clear the SDK error tag from an error object. + * Should be called after reporting the error to prevent memory leaks + * (though WeakSet already handles this automatically). + * + * @param error - The error to clear + */ +export function clearPendingSDKError(error: unknown): void { + if (error instanceof Error) { + pendingSDKErrors.delete(error); + } +} diff --git a/packages/analytics-core/src/diagnostics/diagnostics-uncaught-sdk-error-web-handlers.ts b/packages/analytics-core/src/diagnostics/diagnostics-uncaught-sdk-error-web-handlers.ts new file mode 100644 index 000000000..0575b2fa5 --- /dev/null +++ b/packages/analytics-core/src/diagnostics/diagnostics-uncaught-sdk-error-web-handlers.ts @@ -0,0 +1,154 @@ +/** + * Error Diagnostics for Amplitude SDK + * + * Captures uncaught errors originating from SDK code using execution context tracking. + * Integrates with the Diagnostics Client to report SDK errors for monitoring and debugging. + */ + +import { isPendingSDKError, clearPendingSDKError } from './diagnostics-uncaught-sdk-error-global-tracker'; +import { getGlobalScope } from '../global-scope'; +import { IDiagnosticsClient } from './diagnostics-client'; + +interface ErrorInfo { + message: string; + name: string; + stack?: string; + error_location?: string; + error_line?: number; + error_column?: number; +} + +// Track if error handlers are already setup to prevent duplicates +let isSetup = false; +let diagnosticsClient: IDiagnosticsClient | null = null; + +/** + * Setup global error tracking for Amplitude SDK errors. + * + * This sets up global error event listeners that work with execution context + * tracking to identify and report SDK errors. + * + * @param client - The diagnostics client to report errors to + * + * @example + * ```typescript + * // During SDK initialization + * setupAmplitudeErrorTracking(diagnosticsClient); + * ``` + */ +export function setupAmplitudeErrorTracking(client: IDiagnosticsClient): void { + if (isSetup) { + return; + } + + diagnosticsClient = client; + + setupWindowErrorHandler(); + setupUnhandledRejectionHandler(); + + isSetup = true; +} + +/** + * Setup window error event listener to catch synchronous errors + */ +function setupWindowErrorHandler(): void { + const globalScope = getGlobalScope(); + if (!globalScope) { + return; + } + + globalScope.addEventListener('error', (event: ErrorEvent) => { + if (isPendingSDKError(event.error)) { + const errorInfo = buildErrorInfo(event.error, event.message, event.filename, event.lineno, event.colno); + reportSDKError(errorInfo); + clearPendingSDKError(event.error); + } + }); +} + +/** + * Setup unhandledrejection event listener to catch async errors + */ +function setupUnhandledRejectionHandler(): void { + const globalScope = getGlobalScope(); + if (!globalScope) { + return; + } + + globalScope.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { + if (isPendingSDKError(event.reason)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const error = event.reason; + const errorInfo = buildErrorInfo(error); + reportSDKError(errorInfo); + clearPendingSDKError(error); + } + }); +} + +/** + * Build error information object from error details + */ +function buildErrorInfo( + error: Error | any, + messageOrEvent?: string | Event, + source?: string, + lineno?: number, + colno?: number, +): ErrorInfo { + const errorInfo: ErrorInfo = { + message: '', + name: 'Error', + }; + + // Extract error details + /* istanbul ignore next*/ + if (error && typeof error === 'object') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + errorInfo.message = error.message || String(error); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + errorInfo.name = error.name || 'Error'; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (error.stack) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + errorInfo.stack = error.stack; + } + } else if (error !== undefined && error !== null) { + // Handle primitive error values (strings, numbers, etc.) + errorInfo.message = String(error); + } else if (messageOrEvent) { + errorInfo.message = String(messageOrEvent); + } + + // Add location information if available + if (source) { + errorInfo.error_location = source; + } + if (lineno !== undefined) { + errorInfo.error_line = lineno; + } + if (colno !== undefined) { + errorInfo.error_column = colno; + } + + return errorInfo; +} + +/** + * Report SDK error to diagnostics client + */ +function reportSDKError(errorInfo: ErrorInfo): void { + /* istanbul ignore next */ + if (!diagnosticsClient) { + return; + } + + try { + diagnosticsClient.recordEvent('analytics.errors.uncaught', errorInfo); + } catch (e) { + // Silently fail to prevent infinite error loops + // In production, we don't want error reporting to cause more errors + } +} diff --git a/packages/analytics-core/src/index.ts b/packages/analytics-core/src/index.ts index 21b4db33e..b2a413336 100644 --- a/packages/analytics-core/src/index.ts +++ b/packages/analytics-core/src/index.ts @@ -30,6 +30,7 @@ export { getStorageKey } from './storage/helpers'; export { BrowserStorage } from './storage/browser-storage'; export { DiagnosticsClient, IDiagnosticsClient } from './diagnostics/diagnostics-client'; +export { diagnosticsUncaughtError } from './diagnostics/diagnostics-uncaught-sdk-error-global-tracker'; export { BaseTransport } from './transports/base'; export { FetchTransport } from './transports/fetch'; diff --git a/packages/analytics-core/src/timeline.ts b/packages/analytics-core/src/timeline.ts index 6817fe5eb..3a4c0dff5 100644 --- a/packages/analytics-core/src/timeline.ts +++ b/packages/analytics-core/src/timeline.ts @@ -7,6 +7,7 @@ import { Event } from './types/event/event'; import { Result } from './types/result'; import { buildResult } from './utils/result-builder'; import { UUID } from './utils/uuid'; +import { diagnosticsUncaughtError } from './diagnostics/diagnostics-uncaught-sdk-error-global-tracker'; export class Timeline { queue: [Event, EventCallback][] = []; @@ -22,7 +23,8 @@ export class Timeline { constructor(private client: CoreClient) {} - async register(plugin: Plugin, config: IConfig) { + register = diagnosticsUncaughtError(async (plugin: Plugin, config: IConfig) => { + // Wrap with diagnosticsUncaughtError for plugin setup if (this.plugins.some((existingPlugin) => existingPlugin.name === plugin.name)) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.loggerProvider.warn(`Plugin with name ${plugin.name} already exists, skipping registration`); @@ -39,9 +41,10 @@ export class Timeline { plugin.type = plugin.type ?? 'enrichment'; await plugin.setup?.(config, this.client); this.plugins.push(plugin); - } + }); - async deregister(pluginName: string, config: IConfig) { + deregister = diagnosticsUncaughtError(async (pluginName: string, config: IConfig) => { + // Wrap with diagnosticsUncaughtError for plugin teardown const index = this.plugins.findIndex((plugin) => plugin.name === pluginName); if (index === -1) { config.loggerProvider.warn(`Plugin with name ${pluginName} does not exist, skipping deregistration`); @@ -49,16 +52,17 @@ export class Timeline { } const plugin = this.plugins[index]; this.plugins.splice(index, 1); - await plugin.teardown?.(); - } + void plugin.teardown?.(); + }); - reset(client: CoreClient) { + reset = diagnosticsUncaughtError((client: CoreClient) => { + // Wrap with diagnosticsUncaughtError for plugin teardown this.applying = false; const plugins = this.plugins; plugins.map((plugin) => plugin.teardown?.()); this.plugins = []; this.client = client; - } + }); push(event: Event) { return new Promise((resolve) => { @@ -80,7 +84,8 @@ export class Timeline { }, timeout); } - async apply(item: [Event, EventCallback] | undefined) { + apply = diagnosticsUncaughtError(async (item: [Event, EventCallback] | undefined) => { + // Wrap with diagnosticsUncaughtError for plugin execute if (!item) { return; } @@ -164,7 +169,7 @@ export class Timeline { }); return; - } + }); async flush() { const queue = this.queue; diff --git a/packages/analytics-core/test/diagnostics/diagnostics-uncaught-sdk-error-global-tracker.test.ts b/packages/analytics-core/test/diagnostics/diagnostics-uncaught-sdk-error-global-tracker.test.ts new file mode 100644 index 000000000..84c6b12b4 --- /dev/null +++ b/packages/analytics-core/test/diagnostics/diagnostics-uncaught-sdk-error-global-tracker.test.ts @@ -0,0 +1,329 @@ +import { + diagnosticsUncaughtError, + isPendingSDKError, + clearPendingSDKError, +} from '../../src/diagnostics/diagnostics-uncaught-sdk-error-global-tracker'; + +describe('diagnostics-uncaught-sdk-error-global-tracker', () => { + describe('diagnosticsUncaughtError wrapper', () => { + describe('synchronous functions', () => { + test('should return result for successful sync function', () => { + const syncFn = diagnosticsUncaughtError((value: number) => value * 2); + const result = syncFn(5); + + expect(result).toBe(10); + }); + + test('should tag error and re-throw on sync throw', () => { + const syncFnThrows = diagnosticsUncaughtError(() => { + throw new Error('Sync error'); + }); + + let caughtError: Error | null = null; + try { + syncFnThrows(); + } catch (error) { + caughtError = error as Error; + } + + expect(caughtError).toBeTruthy(); + expect(caughtError?.message).toBe('Sync error'); + expect(isPendingSDKError(caughtError)).toBe(true); + }); + + test('should handle non-Error throws', () => { + const throwString = diagnosticsUncaughtError(() => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'string error'; + }); + + expect(() => throwString()).toThrow('string error'); + }); + + test('should preserve this context', () => { + const obj = { + value: 42, + getValue: diagnosticsUncaughtError(function (this: { value: number }) { + return this.value; + }), + }; + + expect(obj.getValue()).toBe(42); + }); + + test('should preserve arguments', () => { + const fn = diagnosticsUncaughtError((a: number, b: string, c: boolean) => { + return `${a}-${b}-${String(c)}`; + }); + + expect(fn(1, 'test', true)).toBe('1-test-true'); + }); + }); + + describe('asynchronous functions', () => { + test('should return result for successful async function', async () => { + const asyncFn = diagnosticsUncaughtError(async (value: number) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return value * 2; + }); + + const result = await asyncFn(5); + expect(result).toBe(10); + }); + + test('should tag error on async throw', async () => { + const asyncFnThrows = diagnosticsUncaughtError(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + throw new Error('Async error'); + }); + + let caughtError: Error | null = null; + try { + await asyncFnThrows(); + } catch (error) { + caughtError = error as Error; + } + + expect(caughtError).toBeTruthy(); + expect(caughtError?.message).toBe('Async error'); + expect(isPendingSDKError(caughtError)).toBe(true); + }); + + test('should tag error thrown immediately in async function', async () => { + const asyncFnThrowsImmediately = diagnosticsUncaughtError(async () => { + throw new Error('Immediate async error'); + }); + + let caughtError: Error | null = null; + try { + await asyncFnThrowsImmediately(); + } catch (error) { + caughtError = error as Error; + } + + expect(caughtError).toBeTruthy(); + expect(caughtError?.message).toBe('Immediate async error'); + expect(isPendingSDKError(caughtError)).toBe(true); + }); + + test('should handle async function that returns non-Error rejection', async () => { + const rejectWithString = diagnosticsUncaughtError(async () => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'string rejection'; + }); + + await expect(rejectWithString()).rejects.toBe('string rejection'); + }); + }); + + describe('Promise-returning functions', () => { + test('should handle Promise-returning function that resolves', async () => { + const returnsPromise = diagnosticsUncaughtError((shouldResolve: boolean) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (shouldResolve) { + resolve('success'); + } else { + reject(new Error('Promise rejection')); + } + }, 10); + }); + }); + + const result = await returnsPromise(true); + expect(result).toBe('success'); + }); + + test('should handle Promise-returning function that rejects', async () => { + const returnsPromise = diagnosticsUncaughtError((shouldResolve: boolean) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (shouldResolve) { + resolve('success'); + } else { + reject(new Error('Promise rejection')); + } + }, 10); + }); + }); + + let caughtError: Error | null = null; + try { + await returnsPromise(false); + } catch (error) { + caughtError = error as Error; + } + + expect(caughtError?.message).toBe('Promise rejection'); + expect(isPendingSDKError(caughtError)).toBe(true); + }); + }); + + describe('nested wrapped functions', () => { + test('should handle nested wrapped function calls', () => { + const inner = diagnosticsUncaughtError(() => 5); + const outer = diagnosticsUncaughtError(() => inner() * 2); + + const result = outer(); + expect(result).toBe(10); + }); + + test('should tag error when inner function throws', () => { + const innerThrows = diagnosticsUncaughtError(() => { + throw new Error('Inner error'); + }); + const outerCalls = diagnosticsUncaughtError(() => { + innerThrows(); + }); + + let caughtError: Error | null = null; + try { + outerCalls(); + } catch (error) { + caughtError = error as Error; + } + + expect(caughtError?.message).toBe('Inner error'); + expect(isPendingSDKError(caughtError)).toBe(true); + }); + }); + + describe('class method wrapping', () => { + test('should work as arrow function property', () => { + class TestClass { + value = 10; + multiply = diagnosticsUncaughtError((factor: number) => { + return this.value * factor; + }); + } + + const instance = new TestClass(); + expect(instance.multiply(3)).toBe(30); + }); + + test('should tag error from wrapped class method', () => { + class TestClass { + throwError = diagnosticsUncaughtError(() => { + throw new Error('Class method error'); + }); + } + + const instance = new TestClass(); + let caughtError: Error | null = null; + try { + instance.throwError(); + } catch (error) { + caughtError = error as Error; + } + + expect(caughtError?.message).toBe('Class method error'); + expect(isPendingSDKError(caughtError)).toBe(true); + }); + + test('should work with async class methods', async () => { + class TestClass { + asyncMethod = diagnosticsUncaughtError(async (value: number) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return value * 2; + }); + } + + const instance = new TestClass(); + const result = await instance.asyncMethod(5); + expect(result).toBe(10); + }); + }); + }); + + describe('isPendingSDKError', () => { + test('should return false for non-Error values', () => { + expect(isPendingSDKError(null)).toBe(false); + expect(isPendingSDKError(undefined)).toBe(false); + expect(isPendingSDKError('string')).toBe(false); + expect(isPendingSDKError(123)).toBe(false); + expect(isPendingSDKError({})).toBe(false); + }); + + test('should return false for untagged Error', () => { + const error = new Error('Untagged error'); + expect(isPendingSDKError(error)).toBe(false); + }); + + test('should return true for tagged Error from wrapped function', () => { + const throwError = diagnosticsUncaughtError(() => { + throw new Error('Tagged error'); + }); + + let error: Error | null = null; + try { + throwError(); + } catch (e) { + error = e as Error; + } + + expect(isPendingSDKError(error)).toBe(true); + }); + + test('should return true for tagged Error from async wrapped function', async () => { + const asyncThrowError = diagnosticsUncaughtError(async () => { + throw new Error('Tagged async error'); + }); + + let error: Error | null = null; + try { + await asyncThrowError(); + } catch (e) { + error = e as Error; + } + + expect(isPendingSDKError(error)).toBe(true); + }); + }); + + describe('clearPendingSDKError', () => { + test('should handle non-Error values safely', () => { + expect(() => clearPendingSDKError(null)).not.toThrow(); + expect(() => clearPendingSDKError(undefined)).not.toThrow(); + expect(() => clearPendingSDKError('string')).not.toThrow(); + expect(() => clearPendingSDKError(123)).not.toThrow(); + }); + + test('should clear pending SDK error tag', () => { + const throwError = diagnosticsUncaughtError(() => { + throw new Error('Error to clear'); + }); + + let error: Error | null = null; + try { + throwError(); + } catch (e) { + error = e as Error; + } + + expect(isPendingSDKError(error)).toBe(true); + + clearPendingSDKError(error); + + expect(isPendingSDKError(error)).toBe(false); + }); + + test('should be idempotent', () => { + const throwError = diagnosticsUncaughtError(() => { + throw new Error('Error to clear'); + }); + + let error: Error | null = null; + try { + throwError(); + } catch (e) { + error = e as Error; + } + + clearPendingSDKError(error); + clearPendingSDKError(error); + clearPendingSDKError(error); + + expect(isPendingSDKError(error)).toBe(false); + }); + }); +}); diff --git a/packages/analytics-core/test/diagnostics/diagnostics-uncaught-sdk-error-web-handlers.test.ts b/packages/analytics-core/test/diagnostics/diagnostics-uncaught-sdk-error-web-handlers.test.ts new file mode 100644 index 000000000..dabdee938 --- /dev/null +++ b/packages/analytics-core/test/diagnostics/diagnostics-uncaught-sdk-error-web-handlers.test.ts @@ -0,0 +1,430 @@ +import { IDiagnosticsClient } from '../../src/diagnostics/diagnostics-client'; + +// Mock dependencies +jest.mock('../../src/global-scope'); + +describe('diagnostics-uncaught-sdk-error-web-handlers', () => { + let setupAmplitudeErrorTracking: typeof import('../../src/diagnostics/diagnostics-uncaught-sdk-error-web-handlers').setupAmplitudeErrorTracking; + let getGlobalScope: jest.Mock; + let globalTracker: typeof import('../../src/diagnostics/diagnostics-uncaught-sdk-error-global-tracker'); + let mockClient: IDiagnosticsClient; + let mockGlobalScope: { addEventListener: jest.Mock }; + let errorListener: ((event: ErrorEvent) => void) | null = null; + let rejectionListener: ((event: PromiseRejectionEvent) => void) | null = null; + + beforeEach(() => { + // Reset modules to get fresh instance with clean state + jest.resetModules(); + + // Re-import after reset to get fresh modules + // eslint-disable-next-line @typescript-eslint/no-var-requires + setupAmplitudeErrorTracking = + require('../../src/diagnostics/diagnostics-uncaught-sdk-error-web-handlers').setupAmplitudeErrorTracking; + // eslint-disable-next-line @typescript-eslint/no-var-requires + getGlobalScope = require('../../src/global-scope').getGlobalScope; + // eslint-disable-next-line @typescript-eslint/no-var-requires + globalTracker = require('../../src/diagnostics/diagnostics-uncaught-sdk-error-global-tracker'); + + // Setup mock diagnostics client + mockClient = { + recordEvent: jest.fn(), + setTag: jest.fn(), + increment: jest.fn(), + recordHistogram: jest.fn(), + saveAllDataToStorage: jest.fn(), + startTimersIfNeeded: jest.fn(), + initializeFlushInterval: jest.fn(), + _flush: jest.fn(), + fetch: jest.fn(), + _setSampleRate: jest.fn(), + } as unknown as IDiagnosticsClient; + + // Setup mock global scope + errorListener = null; + rejectionListener = null; + mockGlobalScope = { + addEventListener: jest.fn((event: string, listener: (event: ErrorEvent | PromiseRejectionEvent) => void) => { + if (event === 'error') { + errorListener = listener as (event: ErrorEvent) => void; + } else if (event === 'unhandledrejection') { + rejectionListener = listener as (event: PromiseRejectionEvent) => void; + } + }), + }; + getGlobalScope.mockReturnValue(mockGlobalScope); + }); + + describe('setupAmplitudeErrorTracking', () => { + test('should setup error and rejection handlers', () => { + setupAmplitudeErrorTracking(mockClient); + + expect(getGlobalScope).toHaveBeenCalled(); + expect(mockGlobalScope.addEventListener).toHaveBeenCalledWith('error', expect.any(Function)); + expect(mockGlobalScope.addEventListener).toHaveBeenCalledWith('unhandledrejection', expect.any(Function)); + expect(errorListener).not.toBeNull(); + expect(rejectionListener).not.toBeNull(); + }); + + test('should not setup handlers twice (duplicate prevention)', () => { + setupAmplitudeErrorTracking(mockClient); + setupAmplitudeErrorTracking(mockClient); + + // Should only be called once for error and once for unhandledrejection + expect(mockGlobalScope.addEventListener).toHaveBeenCalledTimes(2); + }); + + test('should handle missing global scope', () => { + getGlobalScope.mockReturnValue(null); + + setupAmplitudeErrorTracking(mockClient); + + // Should not throw and should not try to add listeners + expect(mockGlobalScope.addEventListener).not.toHaveBeenCalled(); + }); + }); + + describe('error handler (window.onerror)', () => { + let isPendingSDKErrorSpy: jest.SpyInstance; + let clearPendingSDKErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // Setup spies BEFORE calling setupAmplitudeErrorTracking + isPendingSDKErrorSpy = jest.spyOn(globalTracker, 'isPendingSDKError'); + clearPendingSDKErrorSpy = jest.spyOn(globalTracker, 'clearPendingSDKError'); + + // Now setup error tracking with spies in place + setupAmplitudeErrorTracking(mockClient); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should report pending SDK errors', () => { + isPendingSDKErrorSpy.mockReturnValue(true); + + const error = new Error('Test SDK error'); + const errorEvent = { + error, + message: 'Test SDK error', + filename: 'test.js', + lineno: 10, + colno: 5, + } as ErrorEvent; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + errorListener!(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).toHaveBeenCalledWith('analytics.errors.uncaught', { + message: 'Test SDK error', + name: 'Error', + stack: expect.any(String), + error_location: 'test.js', + error_line: 10, + error_column: 5, + }); + expect(clearPendingSDKErrorSpy).toHaveBeenCalledWith(error); + }); + + test('should not report non-SDK errors', () => { + isPendingSDKErrorSpy.mockReturnValue(false); + + const error = new Error('Non-SDK error'); + const errorEvent = { + error, + message: 'Non-SDK error', + filename: 'external.js', + lineno: 100, + colno: 50, + } as ErrorEvent; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + errorListener!(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + expect(clearPendingSDKErrorSpy).not.toHaveBeenCalled(); + }); + + test('should handle error with custom error type', () => { + isPendingSDKErrorSpy.mockReturnValue(true); + + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + + const error = new CustomError('Custom error message'); + const errorEvent = { + error, + message: 'Custom error message', + filename: 'custom.js', + lineno: 15, + colno: 20, + } as ErrorEvent; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + errorListener!(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).toHaveBeenCalledWith('analytics.errors.uncaught', { + message: 'Custom error message', + name: 'CustomError', + stack: expect.any(String), + error_location: 'custom.js', + error_line: 15, + error_column: 20, + }); + }); + + test('should not report null errors (not pending SDK errors)', () => { + // null errors can't be in the WeakSet, so isPendingSDKError returns false + isPendingSDKErrorSpy.mockReturnValue(false); + + const errorEvent = { + error: null, + message: 'String error message', + filename: 'nostack.js', + lineno: 5, + colno: 10, + } as ErrorEvent; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + errorListener!(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + }); + + test('should handle error without location information', () => { + isPendingSDKErrorSpy.mockReturnValue(true); + + const error = new Error('No location'); + const errorEvent = { + error, + message: 'No location', + } as ErrorEvent; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + errorListener!(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).toHaveBeenCalledWith('analytics.errors.uncaught', { + message: 'No location', + name: 'Error', + stack: expect.any(String), + // Note: no error_location, error_line, error_column + }); + }); + + test('should silently fail if recordEvent throws', () => { + isPendingSDKErrorSpy.mockReturnValue(true); + + (mockClient.recordEvent as jest.Mock).mockImplementation(() => { + throw new Error('RecordEvent failed'); + }); + + const error = new Error('Test error'); + const errorEvent = { + error, + message: 'Test error', + filename: 'test.js', + lineno: 10, + colno: 5, + } as ErrorEvent; + + // Should not throw + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(() => errorListener!(errorEvent)).not.toThrow(); + }); + }); + + describe('unhandled rejection handler', () => { + let isPendingSDKErrorSpy: jest.SpyInstance; + let clearPendingSDKErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // Setup spies BEFORE calling setupAmplitudeErrorTracking + isPendingSDKErrorSpy = jest.spyOn(globalTracker, 'isPendingSDKError'); + clearPendingSDKErrorSpy = jest.spyOn(globalTracker, 'clearPendingSDKError'); + + // Now setup error tracking with spies in place + setupAmplitudeErrorTracking(mockClient); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should report pending SDK rejections', () => { + isPendingSDKErrorSpy.mockReturnValue(true); + + const error = new Error('Async SDK error'); + const rejectedPromise = Promise.reject(error); + rejectedPromise.catch(() => void 0); // Prevent unhandled rejection + + const rejectionEvent = { + reason: error, + promise: rejectedPromise, + } as unknown as PromiseRejectionEvent; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + rejectionListener!(rejectionEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).toHaveBeenCalledWith('analytics.errors.uncaught', { + message: 'Async SDK error', + name: 'Error', + stack: expect.any(String), + }); + expect(clearPendingSDKErrorSpy).toHaveBeenCalledWith(error); + }); + + test('should not report non-SDK rejections', () => { + isPendingSDKErrorSpy.mockReturnValue(false); + + const error = new Error('Non-SDK rejection'); + const rejectedPromise = Promise.reject(error); + rejectedPromise.catch(() => void 0); // Prevent unhandled rejection + + const rejectionEvent = { + reason: error, + promise: rejectedPromise, + } as unknown as PromiseRejectionEvent; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + rejectionListener!(rejectionEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + expect(clearPendingSDKErrorSpy).not.toHaveBeenCalled(); + }); + + test('should handle non-Error rejection reasons', () => { + // String rejections won't be pending SDK errors since isPendingSDKError checks instanceof Error + isPendingSDKErrorSpy.mockReturnValue(false); + + const rejectedPromise = Promise.reject('String rejection reason'); + rejectedPromise.catch(() => void 0); // Prevent unhandled rejection + + const rejectionEvent = { + reason: 'String rejection reason', + promise: rejectedPromise, + } as unknown as PromiseRejectionEvent; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + rejectionListener!(rejectionEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + }); + + test('should handle custom error types in rejections', () => { + isPendingSDKErrorSpy.mockReturnValue(true); + + class NetworkError extends Error { + constructor(message: string) { + super(message); + this.name = 'NetworkError'; + } + } + + const error = new NetworkError('Network request failed'); + const rejectedPromise = Promise.reject(error); + rejectedPromise.catch(() => void 0); // Prevent unhandled rejection + + const rejectionEvent = { + reason: error, + promise: rejectedPromise, + } as unknown as PromiseRejectionEvent; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + rejectionListener!(rejectionEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).toHaveBeenCalledWith('analytics.errors.uncaught', { + message: 'Network request failed', + name: 'NetworkError', + stack: expect.any(String), + }); + }); + + test('should silently fail if recordEvent throws', () => { + isPendingSDKErrorSpy.mockReturnValue(true); + + (mockClient.recordEvent as jest.Mock).mockImplementation(() => { + throw new Error('RecordEvent failed'); + }); + + const error = new Error('Test rejection'); + const rejectedPromise = Promise.reject(error); + rejectedPromise.catch(() => void 0); // Prevent unhandled rejection + + const rejectionEvent = { + reason: error, + promise: rejectedPromise, + } as unknown as PromiseRejectionEvent; + + // Should not throw + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(() => rejectionListener!(rejectionEvent)).not.toThrow(); + }); + }); + + describe('integration scenarios', () => { + test('should handle both error types in sequence', () => { + const isPendingSDKErrorSpy = jest.spyOn(globalTracker, 'isPendingSDKError'); + isPendingSDKErrorSpy.mockReturnValue(true); + + setupAmplitudeErrorTracking(mockClient); + + // Trigger window error + const error1 = new Error('First error'); + const errorEvent = { + error: error1, + message: 'First error', + filename: 'test1.js', + lineno: 10, + colno: 5, + } as ErrorEvent; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + errorListener!(errorEvent); + + // Trigger unhandled rejection + const error2 = new Error('Second error'); + const rejectedPromise = Promise.reject(error2); + rejectedPromise.catch(() => void 0); // Prevent unhandled rejection + + const rejectionEvent = { + reason: error2, + promise: rejectedPromise, + } as unknown as PromiseRejectionEvent; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + rejectionListener!(rejectionEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).toHaveBeenCalledTimes(2); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).toHaveBeenNthCalledWith( + 1, + 'analytics.errors.uncaught', + expect.objectContaining({ + message: 'First error', + }), + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.recordEvent).toHaveBeenNthCalledWith( + 2, + 'analytics.errors.uncaught', + expect.objectContaining({ + message: 'Second error', + }), + ); + + jest.restoreAllMocks(); + }); + }); +}); diff --git a/packages/analytics-core/test/index.test.ts b/packages/analytics-core/test/index.test.ts index 168bcd0b1..b23b643c4 100644 --- a/packages/analytics-core/test/index.test.ts +++ b/packages/analytics-core/test/index.test.ts @@ -61,6 +61,7 @@ import { generateHashCode, isTimestampInSample, DiagnosticsClient, + diagnosticsUncaughtError, } from '../src/index'; describe('index', () => { @@ -141,6 +142,7 @@ describe('index', () => { expect(typeof generateHashCode).toBe('function'); expect(typeof isTimestampInSample).toBe('function'); expect(typeof DiagnosticsClient).toBe('function'); + expect(typeof diagnosticsUncaughtError).toBe('function'); }); describe('replaceSensitiveString export', () => { diff --git a/test-server/diagnostics.html b/test-server/diagnostics.html index 4d38e5b84..921fdda3d 100644 --- a/test-server/diagnostics.html +++ b/test-server/diagnostics.html @@ -1,4 +1,4 @@ - + @@ -9,72 +9,257 @@ body { font-family: Arial, sans-serif; padding: 20px; + margin: 0; } .container { - max-width: 600px; + max-width: 1200px; + width: 100%; + margin: 0 auto; + } + .description { + background-color: #f5f5f5; + padding: 20px; + border-radius: 8px; + margin: 20px 0; + } + .description h2 { + margin-top: 0; + color: #333; + } + .description h3 { + color: #555; + margin-top: 20px; + margin-bottom: 10px; + } + .description ol { + line-height: 1.8; + } + .description ul { + line-height: 1.6; + margin-top: 8px; + } + .description p { + line-height: 1.6; + } + .description code { + background-color: #e0e0e0; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', monospace; + font-size: 0.9em; + } + .description pre { + background-color: #2d2d2d; + color: #f8f8f2; + padding: 12px; + border-radius: 5px; + overflow-x: auto; + margin: 8px 0; + } + .description pre code { + background-color: transparent; + padding: 0; + color: #f8f8f2; + font-size: 0.85em; + } + h3 { + color: #444; + margin-top: 30px; + margin-bottom: 10px; } button { padding: 10px 20px; margin: 10px 5px 10px 0; cursor: pointer; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + } + button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + button.caught { + background-color: #4caf50; + color: white; + border-color: #45a049; + } + button.caught:hover:not(:disabled) { + background-color: #45a049; + } + button.not-caught { + background-color: #f44336; + color: white; + border-color: #da190b; + } + button.not-caught:hover:not(:disabled) { + background-color: #da190b; }

SDK Diagnostics Test

+ +
+

How to Use This Page

+ +

+ First step: Click the "Initialize SDK" button to initialize Amplitude. This uses a backdoor + to set the diagnostics sample rate to 1 (100%), ensuring all diagnostics events are tracked. +

+ +

Test 1: Dropped Events (Missing User/Device ID)

+
    +
  1. + Add Plugin: Click the "Add Plugin (Remove User/Device ID)" button to add a plugin that + removes user_id and device_id from events. This mimics dropped events and triggers diagnostics tracking. +
  2. +
  3. + View Diagnostics: Open DevTools → Application → IndexedDB to see diagnostics metrics + tracked and saved in the database. +
  4. +
+ +

Test 2: Uncaught Errors

+

+ Use the error test buttons to trigger different types of uncaught errors. Buttons are color-coded: + Green = caught by diagnostics, Red = + NOT caught by diagnostics. +

+
    +
  1. + Plugin Setup Error (✅ caught by diagnostics): +
    amplitude.add({
    +  name: 'error-plugin',
    +  type: 'enrichment',
    +  setup: () => {
    +    throw new Error('Uncaught error in setup');
    +  }
    +});
    +
  2. +
  3. + Track with Error Callback (❌ NOT caught by diagnostics - outside SDK execution context): +
    amplitude.track('event').promise.then(() => {
    +  throw new Error('Uncaught error in callback');
    +});
    +
  4. +
  5. + Synchronous Error (❌ NOT caught by diagnostics - outside SDK execution context): +
    throw new Error('Synchronous error in event handler');
    +
  6. +
  7. + Synchronous Error After Init (❌ NOT caught by diagnostics - happens when Initialize SDK button is clicked): +
    amplitude.init(apiKey, userId).promise.then(() => { /* ... */ });
    +throw new Error('Synchronous error after init');
    +

    This error is thrown immediately after calling init(), before the promise resolves.

    +
  8. +
+

+ View Results: Check DevTools Console for error logs and Application → IndexedDB for + diagnostics events tracking the uncaught errors. +

+
+ + +

Test 1: Dropped Events

- + +

Test 2: Uncaught Errors

+ + +
- diff --git a/tsconfig.json b/tsconfig.json index 34a827ffd..afb0e115d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "declaration": true, "declarationMap": true, "downlevelIteration": true, + "experimentalDecorators": true, "inlineSources": true, "importHelpers": true, "lib": ["es2021", "dom"],