Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/analytics-browser/src/browser-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
RemoteConfig,
Source,
DiagnosticsClient,
DiagnosticsUncaughtError,
} from '@amplitude/analytics-core';
import {
getAttributionTrackingConfig,
Expand Down Expand Up @@ -98,6 +99,8 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient, An
}
return returnWrapper(this._init({ ...options, userId, apiKey }));
}

@DiagnosticsUncaughtError
protected async _init(options: BrowserOptions & { apiKey: string }) {
// Step 1: Block concurrent initialization
if (this.initializing) {
Expand Down Expand Up @@ -495,6 +498,7 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient, An
return true;
}

@DiagnosticsUncaughtError
async process(event: Event) {
const currentTime = Date.now();
const isEventInNewSession = isNewSession(this.config.sessionTimeout, this.config.lastEventTime);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Execution Context Tracker for SDK Error Detection
*
* Identifies SDK-originated errors using two complementary mechanisms:
*
* 1. Execution depth counter: Tracks active SDK execution (increment on entry, decrement on exit).
* Errors occurring while counter > 0 are identified as SDK errors.
*
* 2. Error tagging with WeakSet: Tags error objects thrown from SDK code.
* Enables detection of async errors that surface after the execution context has exited.
*
* Global error handlers check both mechanisms to accurately identify SDK errors.
*/

interface ExecutionContext {
depth: number;
}

// Global execution tracker
const executionTracker: ExecutionContext = {
depth: 0,
};

// Track error objects that originated from SDK code
// Using WeakSet prevents memory leaks as errors are garbage collected
const pendingSDKErrors = new WeakSet<Error>();

/**
* Get the global execution tracker instance
*/
export const getExecutionTracker = () => ({
/**
* Enter SDK execution context
*/
enter(): void {
executionTracker.depth++;
},

/**
* Exit SDK execution context
*/
exit(): void {
executionTracker.depth = Math.max(0, executionTracker.depth - 1);
},

/**
* Check if currently executing SDK code
*/
isInSDKExecution(): boolean {
return executionTracker.depth > 0;
},

/**
* Get current depth (for debugging)
*/
getDepth(): number {
return executionTracker.depth;
},
});

/**
* Method decorator that wraps a class method with execution tracking to identify SDK errors.
*
* This decorator tracks when SDK code is running using a simple counter.
* Any error that occurs while the counter > 0 is identified as an SDK error
* by the global error handlers.
*
* @returns Method decorator
*
* @example
* ```typescript
* class AmplitudeBrowser {
* @DiagnosticsUncaughtError
* async track(event: Event) {
* // ... SDK tracking code ...
* }
* }
* ```
*/
export function DiagnosticsUncaughtError<T extends (...args: any[]) => any>(
_target: any,
_propertyKey: string,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value as T;
const tracker = getExecutionTracker();

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
descriptor.value = function (this: any, ...args: Parameters<T>): ReturnType<T> {
tracker.enter();

try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const result = originalMethod.apply(this, args);
tracker.exit();

// 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) => {
// Tag this error as SDK-originated for later detection
if (error instanceof Error) {
pendingSDKErrors.add(error);
}
throw error;
}) as ReturnType<T>;
}

// If sync, return the result immediately
// 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);
}

// Exit tracking before re-throwing
tracker.exit();
throw error;
}
};

return descriptor;
}

/**
* 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* 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 {
getExecutionTracker,
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;
type: string;
stack?: string;
detection_method: 'execution_tracking';
execution_context: string | null;
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 {
// Prevent duplicate setup
if (isSetup) {
return;
}

diagnosticsClient = client;

// Setup window.onerror handler
setupWindowErrorHandler();

// Setup unhandled rejection handler
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) => {
// Check if this error occurred during SDK execution
const tracker = getExecutionTracker();
const isInSDK = tracker.isInSDKExecution();
const isPendingError = isPendingSDKError(event.error);

// Check both execution depth AND error object tagging
if (isInSDK || isPendingError) {
// This is an SDK error - report it
const errorInfo = buildErrorInfo(event.error, event.message, event.filename, event.lineno, event.colno, null);
reportSDKError(errorInfo);

// Clean up the error tag
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) => {
// Check if this rejection occurred during SDK execution
const tracker = getExecutionTracker();
const isInSDK = tracker.isInSDKExecution();
const isPendingError = isPendingSDKError(event.reason);

// Check both execution depth AND error object tagging
if (isInSDK || isPendingError) {
// This is an SDK error - report it
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const error = event.reason;
const errorInfo = buildErrorInfo(error, undefined, undefined, undefined, undefined, null);
reportSDKError(errorInfo);

// Clean up the error tag
clearPendingSDKError(error);
}
});
}

/**
* Build error information object from error details
*/
function buildErrorInfo(
error: Error | any,
messageOrEvent?: string | Event,
source?: string,
lineno?: number,
colno?: number,
executionContext?: string | null,
): ErrorInfo {
const errorInfo: ErrorInfo = {
message: '',
name: 'Error',
type: 'Error',
detection_method: 'execution_tracking',
execution_context: executionContext || null,
};

// Extract error details
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-assignment, @typescript-eslint/no-unsafe-member-access
errorInfo.type = error.constructor?.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 (typeof messageOrEvent === 'string') {
errorInfo.message = messageOrEvent;
} 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 {
if (!diagnosticsClient) {
return;
}

try {
// Record the error event
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
}
}
1 change: 1 addition & 0 deletions packages/analytics-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading