diff --git a/packages/analytics-browser/src/snippet-index.ts b/packages/analytics-browser/src/snippet-index.ts index a35bf1745..d85b2481d 100644 --- a/packages/analytics-browser/src/snippet-index.ts +++ b/packages/analytics-browser/src/snippet-index.ts @@ -1,8 +1,25 @@ -import { getGlobalScope } from '@amplitude/analytics-core'; +import { getGlobalScope, registerSdkLoaderMetadata } from '@amplitude/analytics-core'; import * as amplitude from './index'; import { createInstance } from './browser-client-factory'; import { runQueuedFunctions } from './utils/snippet-helper'; +registerSdkLoaderMetadata({ + scriptUrl: resolveCurrentScriptUrl(), +}); + +function resolveCurrentScriptUrl(): string | undefined { + if (typeof document === 'undefined') { + return undefined; + } + + const currentScript = document.currentScript as HTMLScriptElement | null; + if (currentScript?.src) { + return currentScript.src; + } + + return undefined; +} + // https://developer.mozilla.org/en-US/docs/Glossary/IIFE (function () { const GlobalScope = getGlobalScope(); diff --git a/packages/analytics-core/src/diagnostics/diagnostics-client.ts b/packages/analytics-core/src/diagnostics/diagnostics-client.ts index 8cce18ff8..615e29a12 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 { enableSdkErrorListeners } from './uncaught-sdk-errors'; export const SAVE_INTERVAL_MS = 1000; // 1 second export const FLUSH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -215,6 +216,7 @@ export class DiagnosticsClient implements IDiagnosticsClient { // Track internal diagnostics metrics for sampling if (this.shouldTrack) { this.increment('sdk.diagnostics.sampled.in.and.enabled'); + enableSdkErrorListeners(this); } } diff --git a/packages/analytics-core/src/diagnostics/uncaught-sdk-errors.ts b/packages/analytics-core/src/diagnostics/uncaught-sdk-errors.ts new file mode 100644 index 000000000..40405dc62 --- /dev/null +++ b/packages/analytics-core/src/diagnostics/uncaught-sdk-errors.ts @@ -0,0 +1,157 @@ +import { getGlobalScope } from '../global-scope'; +import { IDiagnosticsClient } from './diagnostics-client'; + +type ErrorEventType = 'error' | 'unhandledrejection'; + +interface CapturedErrorContext { + readonly type: ErrorEventType; + readonly message: string; + readonly stack?: string; + readonly filename?: string; + readonly errorName?: string; + readonly metadata?: Record; +} + +export const GLOBAL_KEY = '__AMPLITUDE_SCRIPT_URL__'; +export const EVENT_NAME_ERROR_UNCAUGHT = 'sdk.error.uncaught'; + +const getNormalizedScriptUrl = (): string | undefined => { + const scope = getGlobalScope() as Record | null; + /* istanbul ignore next */ + return scope?.[GLOBAL_KEY] as string | undefined; +}; + +const setNormalizedScriptUrl = (url: string) => { + const scope = getGlobalScope() as Record | null; + if (scope) { + scope[GLOBAL_KEY] = url; + } +}; + +export const registerSdkLoaderMetadata = (metadata: { scriptUrl?: string }) => { + if (metadata.scriptUrl) { + const normalized = normalizeUrl(metadata.scriptUrl); + if (normalized) { + setNormalizedScriptUrl(normalized); + } + } +}; + +export const enableSdkErrorListeners = (client: IDiagnosticsClient) => { + const scope = getGlobalScope(); + + if (!scope || typeof scope.addEventListener !== 'function') { + return; + } + + const handleError = (event: ErrorEvent) => { + const error = event.error instanceof Error ? event.error : undefined; + const stack = error?.stack; + const match = detectSdkOrigin({ filename: event.filename, stack }); + if (!match) { + return; + } + + capture({ + type: 'error', + message: event.message, + stack, + filename: event.filename, + errorName: error?.name, + metadata: { + colno: event.colno, + lineno: event.lineno, + isTrusted: event.isTrusted, + matchReason: match, + }, + }); + }; + + const handleRejection = (event: PromiseRejectionEvent) => { + const error = event.reason instanceof Error ? event.reason : undefined; + const stack = error?.stack; + const filename = extractFilenameFromStack(stack); + const match = detectSdkOrigin({ filename, stack }); + + if (!match) { + return; + } + + /* istanbul ignore next */ + capture({ + type: 'unhandledrejection', + message: error?.message ?? stringifyReason(event.reason), + stack, + filename, + errorName: error?.name, + metadata: { + isTrusted: event.isTrusted, + matchReason: match, + }, + }); + }; + + const capture = (context: CapturedErrorContext) => { + client.recordEvent(EVENT_NAME_ERROR_UNCAUGHT, { + type: context.type, + message: context.message, + filename: context.filename, + error_name: context.errorName, + stack: context.stack, + ...context.metadata, + }); + }; + + scope.addEventListener('error', handleError, true); + scope.addEventListener('unhandledrejection', handleRejection, true); +}; + +const detectSdkOrigin = (payload: { filename?: string; stack?: string }): 'filename' | 'stack' | undefined => { + const normalizedScriptUrl = getNormalizedScriptUrl(); + if (!normalizedScriptUrl) { + return undefined; + } + + if (payload.filename && payload.filename.includes(normalizedScriptUrl)) { + return 'filename'; + } + + if (payload.stack && payload.stack.includes(normalizedScriptUrl)) { + return 'stack'; + } + + return undefined; +}; + +const normalizeUrl = (value: string) => { + try { + /* istanbul ignore next */ + const url = new URL(value, getGlobalScope()?.location?.origin); + return url.origin + url.pathname; + } catch { + return undefined; + } +}; + +const extractFilenameFromStack = (stack?: string) => { + if (!stack) { + return undefined; + } + + const match = stack.match(/(https?:\/\/\S+?)(?=[)\s]|$)/); + /* istanbul ignore next */ + return match ? match[1] : undefined; +}; + +/* istanbul ignore next */ +const stringifyReason = (reason: unknown) => { + if (typeof reason === 'string') { + return reason; + } + + try { + return JSON.stringify(reason); + } catch { + return '[object Object]'; + } +}; diff --git a/packages/analytics-core/src/index.ts b/packages/analytics-core/src/index.ts index 78bf84454..91b6a559b 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 { registerSdkLoaderMetadata } from './diagnostics/uncaught-sdk-errors'; export { BaseTransport } from './transports/base'; export { FetchTransport } from './transports/fetch'; diff --git a/packages/analytics-core/test/diagnostics/uncaught-sdk-errors.test.ts b/packages/analytics-core/test/diagnostics/uncaught-sdk-errors.test.ts new file mode 100644 index 000000000..0057ee075 --- /dev/null +++ b/packages/analytics-core/test/diagnostics/uncaught-sdk-errors.test.ts @@ -0,0 +1,360 @@ +import { + registerSdkLoaderMetadata, + enableSdkErrorListeners, + GLOBAL_KEY, + EVENT_NAME_ERROR_UNCAUGHT, +} from '../../src/diagnostics/uncaught-sdk-errors'; +import { IDiagnosticsClient } from '../../src/diagnostics/diagnostics-client'; +import * as globalScopeModule from '../../src/global-scope'; + +describe('uncaught-sdk-errors', () => { + let mockGlobalScope: Record & { + addEventListener: jest.Mock; + removeEventListener: jest.Mock; + }; + let mockDiagnosticsClient: jest.Mocked; + let errorHandler: ((event: ErrorEvent) => void) | undefined; + let rejectionHandler: ((event: PromiseRejectionEvent) => void) | undefined; + + beforeEach(() => { + // Reset global state + mockGlobalScope = { + addEventListener: jest.fn((type: string, handler: unknown) => { + if (type === 'error') { + errorHandler = handler as (event: ErrorEvent) => void; + } else if (type === 'unhandledrejection') { + rejectionHandler = handler as (event: PromiseRejectionEvent) => void; + } + }), + removeEventListener: jest.fn(), + }; + + jest.spyOn(globalScopeModule, 'getGlobalScope').mockReturnValue(mockGlobalScope as unknown as typeof globalThis); + + mockDiagnosticsClient = { + setTag: jest.fn(), + increment: jest.fn(), + recordHistogram: jest.fn(), + recordEvent: jest.fn(), + _flush: jest.fn(), + _setSampleRate: jest.fn(), + }; + + errorHandler = undefined; + rejectionHandler = undefined; + }); + + afterEach(() => { + jest.restoreAllMocks(); + // Clean up global state + delete mockGlobalScope[GLOBAL_KEY]; + }); + + describe('registerSdkLoaderMetadata', () => { + it('should register script URL in global scope', () => { + registerSdkLoaderMetadata({ scriptUrl: 'https://cdn.amplitude.com/libs/amplitude.js' }); + + expect(mockGlobalScope[GLOBAL_KEY]).toBe('https://cdn.amplitude.com/libs/amplitude.js'); + }); + + it('should normalize script URL by removing query params', () => { + registerSdkLoaderMetadata({ scriptUrl: 'https://cdn.amplitude.com/libs/amplitude.js?v=1.0.0' }); + + expect(mockGlobalScope[GLOBAL_KEY]).toBe('https://cdn.amplitude.com/libs/amplitude.js'); + }); + + it('should normalize script URL by removing hash', () => { + registerSdkLoaderMetadata({ scriptUrl: 'https://cdn.amplitude.com/libs/amplitude.js#section' }); + + expect(mockGlobalScope[GLOBAL_KEY]).toBe('https://cdn.amplitude.com/libs/amplitude.js'); + }); + + it('should normalize script URL by removing both query params and hash', () => { + registerSdkLoaderMetadata({ scriptUrl: 'https://cdn.amplitude.com/libs/amplitude.js?v=1.0.0#section' }); + + expect(mockGlobalScope[GLOBAL_KEY]).toBe('https://cdn.amplitude.com/libs/amplitude.js'); + }); + + it('should not register if scriptUrl is undefined', () => { + registerSdkLoaderMetadata({}); + + expect(mockGlobalScope[GLOBAL_KEY]).toBeUndefined(); + }); + + it('should not register if scriptUrl is empty string', () => { + registerSdkLoaderMetadata({ scriptUrl: '' }); + + expect(mockGlobalScope[GLOBAL_KEY]).toBeUndefined(); + }); + + it('should not register if scriptUrl is an invalid URL', () => { + registerSdkLoaderMetadata({ scriptUrl: 'not-a-valid-url' }); + + expect(mockGlobalScope[GLOBAL_KEY]).toBeUndefined(); + }); + }); + + describe('enableSdkErrorListeners', () => { + it('should add error and unhandledrejection event listeners', () => { + enableSdkErrorListeners(mockDiagnosticsClient); + + expect(mockGlobalScope.addEventListener).toHaveBeenCalledWith('error', expect.any(Function), true); + expect(mockGlobalScope.addEventListener).toHaveBeenCalledWith('unhandledrejection', expect.any(Function), true); + }); + + it('should not add listeners if global scope is null', () => { + jest.spyOn(globalScopeModule, 'getGlobalScope').mockReturnValue(null as unknown as typeof globalThis); + + enableSdkErrorListeners(mockDiagnosticsClient); + + expect(mockGlobalScope.addEventListener).not.toHaveBeenCalled(); + }); + + it('should not add listeners if addEventListener is not a function', () => { + const scopeWithoutAddEventListener = { ...mockGlobalScope }; + delete (scopeWithoutAddEventListener as Record).addEventListener; + jest + .spyOn(globalScopeModule, 'getGlobalScope') + .mockReturnValue(scopeWithoutAddEventListener as unknown as typeof globalThis); + + enableSdkErrorListeners(mockDiagnosticsClient); + + // Should not throw + }); + }); + + describe('error handling', () => { + beforeEach(() => { + // Register script URL first + registerSdkLoaderMetadata({ scriptUrl: 'https://cdn.amplitude.com/libs/amplitude.js' }); + enableSdkErrorListeners(mockDiagnosticsClient); + }); + + describe('ErrorEvent handling', () => { + it('should capture error when filename matches SDK script URL', () => { + const error = new Error('Test error'); + const errorEvent = { + message: 'Test error', + filename: 'https://cdn.amplitude.com/libs/amplitude.js', + lineno: 100, + colno: 50, + error, + isTrusted: true, + } as ErrorEvent; + + errorHandler?.(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).toHaveBeenCalledWith(EVENT_NAME_ERROR_UNCAUGHT, { + type: 'error', + message: 'Test error', + filename: 'https://cdn.amplitude.com/libs/amplitude.js', + error_name: 'Error', + stack: error.stack, + colno: 50, + lineno: 100, + isTrusted: true, + matchReason: 'filename', + }); + }); + + it('should capture error when stack trace contains SDK script URL', () => { + const error = new Error('Test error'); + error.stack = `Error: Test error + at functionName (https://cdn.amplitude.com/libs/amplitude.js:100:50) + at anotherFunction (https://example.com/app.js:200:30)`; + + const errorEvent = { + message: 'Test error', + filename: 'https://example.com/app.js', + lineno: 200, + colno: 30, + error, + isTrusted: true, + } as ErrorEvent; + + errorHandler?.(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).toHaveBeenCalledWith(EVENT_NAME_ERROR_UNCAUGHT, { + type: 'error', + message: 'Test error', + filename: 'https://example.com/app.js', + error_name: 'Error', + stack: error.stack, + colno: 30, + lineno: 200, + isTrusted: true, + matchReason: 'stack', + }); + }); + + it('should NOT capture error when neither filename nor stack matches SDK script URL', () => { + const error = new Error('Test error'); + error.stack = `Error: Test error + at functionName (https://example.com/app.js:100:50)`; + + const errorEvent = { + message: 'Test error', + filename: 'https://example.com/app.js', + lineno: 100, + colno: 50, + error, + isTrusted: true, + } as ErrorEvent; + + errorHandler?.(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).not.toHaveBeenCalled(); + }); + + it('should handle error event without error object', () => { + const errorEvent = { + message: 'Script error', + filename: 'https://cdn.amplitude.com/libs/amplitude.js', + lineno: 0, + colno: 0, + error: null, + isTrusted: true, + } as ErrorEvent; + + errorHandler?.(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).toHaveBeenCalledWith(EVENT_NAME_ERROR_UNCAUGHT, { + type: 'error', + message: 'Script error', + filename: 'https://cdn.amplitude.com/libs/amplitude.js', + error_name: undefined, + stack: undefined, + colno: 0, + lineno: 0, + isTrusted: true, + matchReason: 'filename', + }); + }); + }); + + describe('PromiseRejectionEvent handling', () => { + it('should capture unhandled rejection when stack contains SDK script URL', () => { + const error = new Error('Promise rejected'); + error.stack = `Error: Promise rejected + at async functionName (https://cdn.amplitude.com/libs/amplitude.js:100:50)`; + + const rejectionEvent = { + reason: error, + isTrusted: true, + } as PromiseRejectionEvent; + + rejectionHandler?.(rejectionEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).toHaveBeenCalledWith(EVENT_NAME_ERROR_UNCAUGHT, { + type: 'unhandledrejection', + message: 'Promise rejected', + filename: 'https://cdn.amplitude.com/libs/amplitude.js:100:50', + error_name: 'Error', + stack: error.stack, + isTrusted: true, + matchReason: 'filename', + }); + }); + + it('should NOT capture unhandled rejection when stack does not contain SDK script URL', () => { + const error = new Error('Promise rejected'); + error.stack = `Error: Promise rejected + at async functionName (https://example.com/app.js:100:50)`; + + const rejectionEvent = { + reason: error, + isTrusted: true, + } as PromiseRejectionEvent; + + rejectionHandler?.(rejectionEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).not.toHaveBeenCalled(); + }); + + it('should handle non-Error rejection reason as string', () => { + // First need to set up a scenario where it would match + // Since reason is not an Error, stack will be undefined, so it won't match + const rejectionEvent = { + reason: 'Simple string rejection', + isTrusted: true, + } as PromiseRejectionEvent; + + rejectionHandler?.(rejectionEvent); + + // Should not be captured because there's no way to match without stack + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).not.toHaveBeenCalled(); + }); + + it('should handle object rejection reason by stringifying', () => { + const rejectionEvent = { + reason: { code: 'ERR_001', details: 'Something went wrong' }, + isTrusted: true, + } as PromiseRejectionEvent; + + rejectionHandler?.(rejectionEvent); + + // Should not be captured because there's no way to match without stack + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).not.toHaveBeenCalled(); + }); + }); + }); + + describe('without registered script URL', () => { + beforeEach(() => { + // Don't register script URL + enableSdkErrorListeners(mockDiagnosticsClient); + }); + + it('should NOT capture any errors when no script URL is registered', () => { + const error = new Error('Test error'); + const errorEvent = { + message: 'Test error', + filename: 'https://cdn.amplitude.com/libs/amplitude.js', + lineno: 100, + colno: 50, + error, + isTrusted: true, + } as ErrorEvent; + + errorHandler?.(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).not.toHaveBeenCalled(); + }); + }); + + describe('URL normalization edge cases', () => { + it('should handle filename with query params when matching', () => { + registerSdkLoaderMetadata({ scriptUrl: 'https://cdn.amplitude.com/libs/amplitude.js' }); + enableSdkErrorListeners(mockDiagnosticsClient); + + const error = new Error('Test error'); + const errorEvent = { + message: 'Test error', + filename: 'https://cdn.amplitude.com/libs/amplitude.js?v=2.0.0', + lineno: 100, + colno: 50, + error, + isTrusted: true, + } as ErrorEvent; + + errorHandler?.(errorEvent); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockDiagnosticsClient.recordEvent).toHaveBeenCalledWith( + EVENT_NAME_ERROR_UNCAUGHT, + expect.objectContaining({ + matchReason: 'filename', + }), + ); + }); + }); +}); diff --git a/packages/analytics-core/test/index.test.ts b/packages/analytics-core/test/index.test.ts index 168bcd0b1..14204b88c 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, + registerSdkLoaderMetadata, } 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 registerSdkLoaderMetadata).toBe('function'); }); describe('replaceSensitiveString export', () => { diff --git a/test-server/diagnostics.html b/test-server/diagnostics.html index 4d38e5b84..cbc53b661 100644 --- a/test-server/diagnostics.html +++ b/test-server/diagnostics.html @@ -7,51 +7,141 @@ SDK Diagnostics Test + + + -
-

SDK Diagnostics Test

- - - +

SDK Diagnostics Test

+

This page tests SDK diagnostics including uncaught SDK error capture. SDK is loaded via <script> tag.

+ +
+

SDK Initialization

+
+ + +
- -