Skip to content
Merged
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
19 changes: 18 additions & 1 deletion packages/analytics-browser/src/snippet-index.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
2 changes: 2 additions & 0 deletions packages/analytics-core/src/diagnostics/diagnostics-client.ts
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 { enableSdkErrorListeners } from './uncaught-sdk-errors';

export const SAVE_INTERVAL_MS = 1000; // 1 second
export const FLUSH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
Expand Down Expand Up @@ -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);
}
}

Expand Down
157 changes: 157 additions & 0 deletions packages/analytics-core/src/diagnostics/uncaught-sdk-errors.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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<string, unknown> | null;
/* istanbul ignore next */
return scope?.[GLOBAL_KEY] as string | undefined;
};

const setNormalizedScriptUrl = (url: string) => {
const scope = getGlobalScope() as Record<string, unknown> | 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]';
}
};
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 { registerSdkLoaderMetadata } from './diagnostics/uncaught-sdk-errors';

export { BaseTransport } from './transports/base';
export { FetchTransport } from './transports/fetch';
Expand Down
Loading
Loading