Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions packages/analytics-browser/src/browser-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
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,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<Error>();

/**
* 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<T extends (...args: any[]) => any>(fn: T): T {
return function (this: any, ...args: Parameters<T>): ReturnType<T> {
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<T>;
}

// 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);
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
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
23 changes: 14 additions & 9 deletions packages/analytics-core/src/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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][] = [];
Expand All @@ -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`);
Expand All @@ -39,26 +41,28 @@ 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`);
return;
}
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<Result>((resolve) => {
Expand All @@ -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;
}
Expand Down Expand Up @@ -164,7 +169,7 @@ export class Timeline {
});

return;
}
});

async flush() {
const queue = this.queue;
Expand Down
Loading
Loading