diff --git a/src/js/integrations/debugsymbolicator.ts b/src/js/integrations/debugsymbolicator.ts index 048387dfcd..78100cdd6f 100644 --- a/src/js/integrations/debugsymbolicator.ts +++ b/src/js/integrations/debugsymbolicator.ts @@ -1,10 +1,9 @@ -import type { Event, EventHint, EventProcessor, Hub, Integration, StackFrame as SentryStackFrame } from '@sentry/types'; +import type { Event, EventHint, IntegrationFn, StackFrame as SentryStackFrame } from '@sentry/types'; import { addContextToFrame, logger } from '@sentry/utils'; import { getFramesToPop, isErrorLike } from '../utils/error'; -import { ReactNativeLibraries } from '../utils/rnlibraries'; -import { createStealthXhr, XHR_READYSTATE_DONE } from '../utils/xhr'; import type * as ReactNative from '../vendor/react-native'; +import { fetchSourceContext, getDevServer, parseErrorStack, symbolicateStackTrace } from './debugsymbolicatorutils'; // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor const INTERNAL_CALLSITES_REGEX = new RegExp(['ReactNativeRenderer-dev\\.js$', 'MessageQueue\\.js$'].join('|')); @@ -20,255 +19,163 @@ export type ReactNativeError = Error & { }; /** Tries to symbolicate the JS stack trace on the device. */ -export class DebugSymbolicator implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'DebugSymbolicator'; - /** - * @inheritDoc - */ - public name: string = DebugSymbolicator.id; - - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - addGlobalEventProcessor(async (event: Event, hint: EventHint) => { - const self = getCurrentHub().getIntegration(DebugSymbolicator); - - if (!self) { - return event; - } - - if (event.exception && isErrorLike(hint.originalException)) { - // originalException is ErrorLike object - const symbolicatedFrames = await this._symbolicate( - hint.originalException.stack, - getFramesToPop(hint.originalException as Error), - ); - symbolicatedFrames && this._replaceExceptionFramesInEvent(event, symbolicatedFrames); - } else if (hint.syntheticException && isErrorLike(hint.syntheticException)) { - // syntheticException is Error object - const symbolicatedFrames = await this._symbolicate( - hint.syntheticException.stack, - getFramesToPop(hint.syntheticException), - ); +export const debugSymbolicatorIntegration: IntegrationFn = () => { + return { + name: 'DebugSymbolicator', + setupOnce: () => { + /* noop */ + }, + processEvent, + }; +}; - if (event.exception) { - symbolicatedFrames && this._replaceExceptionFramesInEvent(event, symbolicatedFrames); - } else if (event.threads) { - // RN JS doesn't have threads - symbolicatedFrames && this._replaceThreadFramesInEvent(event, symbolicatedFrames); - } - } +async function processEvent(event: Event, hint: EventHint): Promise { + if (event.exception && isErrorLike(hint.originalException)) { + // originalException is ErrorLike object + const symbolicatedFrames = await symbolicate( + hint.originalException.stack, + getFramesToPop(hint.originalException as Error), + ); + symbolicatedFrames && replaceExceptionFramesInEvent(event, symbolicatedFrames); + } else if (hint.syntheticException && isErrorLike(hint.syntheticException)) { + // syntheticException is Error object + const symbolicatedFrames = await symbolicate( + hint.syntheticException.stack, + getFramesToPop(hint.syntheticException), + ); - return event; - }); + if (event.exception) { + symbolicatedFrames && replaceExceptionFramesInEvent(event, symbolicatedFrames); + } else if (event.threads) { + // RN JS doesn't have threads + symbolicatedFrames && replaceThreadFramesInEvent(event, symbolicatedFrames); + } } - /** - * Symbolicates the stack on the device talking to local dev server. - * Mutates the passed event. - */ - private async _symbolicate(rawStack: string, skipFirstFrames: number = 0): Promise { - try { - const parsedStack = this._parseErrorStack(rawStack); - - const prettyStack = await this._symbolicateStackTrace(parsedStack); - if (!prettyStack) { - logger.error('React Native DevServer could not symbolicate the stack trace.'); - return null; - } - - // This has been changed in an react-native version so stack is contained in here - const newStack = prettyStack.stack || prettyStack; - - // https://github.com/getsentry/sentry-javascript/blob/739d904342aaf9327312f409952f14ceff4ae1ab/packages/utils/src/stacktrace.ts#L23 - // Match SentryParser which counts lines of stack (-1 for first line with the Error message) - const skipFirstAdjustedToSentryStackParser = Math.max(skipFirstFrames - 1, 0); - const stackWithoutPoppedFrames = skipFirstAdjustedToSentryStackParser - ? newStack.slice(skipFirstAdjustedToSentryStackParser) - : newStack; + return event; +} - const stackWithoutInternalCallsites = stackWithoutPoppedFrames.filter( - (frame: { file?: string }) => frame.file && frame.file.match(INTERNAL_CALLSITES_REGEX) === null, - ); +/** + * Symbolicates the stack on the device talking to local dev server. + * Mutates the passed event. + */ +async function symbolicate(rawStack: string, skipFirstFrames: number = 0): Promise { + try { + const parsedStack = parseErrorStack(rawStack); - return await this._convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites); - } catch (error) { - if (error instanceof Error) { - logger.warn(`Unable to symbolicate stack trace: ${error.message}`); - } + const prettyStack = await symbolicateStackTrace(parsedStack); + if (!prettyStack) { + logger.error('React Native DevServer could not symbolicate the stack trace.'); return null; } - } - - /** - * Converts ReactNativeFrames to frames in the Sentry format - * @param frames ReactNativeFrame[] - */ - private async _convertReactNativeFramesToSentryFrames(frames: ReactNative.StackFrame[]): Promise { - return Promise.all( - frames.map(async (frame: ReactNative.StackFrame): Promise => { - let inApp = !!frame.column && !!frame.lineNumber; - inApp = - inApp && - frame.file !== undefined && - !frame.file.includes('node_modules') && - !frame.file.includes('native code'); - const newFrame: SentryStackFrame = { - lineno: frame.lineNumber, - colno: frame.column, - filename: frame.file, - function: frame.methodName, - in_app: inApp, - }; + // This has been changed in an react-native version so stack is contained in here + const newStack = prettyStack.stack || prettyStack; - if (inApp) { - await this._addSourceContext(newFrame); - } + // https://github.com/getsentry/sentry-javascript/blob/739d904342aaf9327312f409952f14ceff4ae1ab/packages/utils/src/stacktrace.ts#L23 + // Match SentryParser which counts lines of stack (-1 for first line with the Error message) + const skipFirstAdjustedToSentryStackParser = Math.max(skipFirstFrames - 1, 0); + const stackWithoutPoppedFrames = skipFirstAdjustedToSentryStackParser + ? newStack.slice(skipFirstAdjustedToSentryStackParser) + : newStack; - return newFrame; - }), + const stackWithoutInternalCallsites = stackWithoutPoppedFrames.filter( + (frame: { file?: string }) => frame.file && frame.file.match(INTERNAL_CALLSITES_REGEX) === null, ); - } - /** - * Replaces the frames in the exception of a error. - * @param event Event - * @param frames StackFrame[] - */ - private _replaceExceptionFramesInEvent(event: Event, frames: SentryStackFrame[]): void { - if ( - event.exception && - event.exception.values && - event.exception.values[0] && - event.exception.values[0].stacktrace - ) { - event.exception.values[0].stacktrace.frames = frames.reverse(); + return await convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites); + } catch (error) { + if (error instanceof Error) { + logger.warn(`Unable to symbolicate stack trace: ${error.message}`); } + return null; } +} - /** - * Replaces the frames in the thread of a message. - * @param event Event - * @param frames StackFrame[] - */ - private _replaceThreadFramesInEvent(event: Event, frames: SentryStackFrame[]): void { - if (event.threads && event.threads.values && event.threads.values[0] && event.threads.values[0].stacktrace) { - event.threads.values[0].stacktrace.frames = frames.reverse(); - } - } - - /** - * This tries to add source context for in_app Frames - * - * @param frame StackFrame - * @param getDevServer function from RN to get DevServer URL - */ - private async _addSourceContext(frame: SentryStackFrame): Promise { - let sourceContext: string | null = null; - - const segments = frame.filename?.split('/') ?? []; - - const serverUrl = this._getDevServer()?.url; - if (!serverUrl) { - return; - } - - for (const idx in segments) { - if (!Object.prototype.hasOwnProperty.call(segments, idx)) { - continue; - } +/** + * Converts ReactNativeFrames to frames in the Sentry format + * @param frames ReactNativeFrame[] + */ +async function convertReactNativeFramesToSentryFrames(frames: ReactNative.StackFrame[]): Promise { + return Promise.all( + frames.map(async (frame: ReactNative.StackFrame): Promise => { + let inApp = !!frame.column && !!frame.lineNumber; + inApp = + inApp && + frame.file !== undefined && + !frame.file.includes('node_modules') && + !frame.file.includes('native code'); + + const newFrame: SentryStackFrame = { + lineno: frame.lineNumber, + colno: frame.column, + filename: frame.file, + function: frame.methodName, + in_app: inApp, + }; - sourceContext = await this._fetchSourceContext(serverUrl, segments, -idx); - if (sourceContext) { - break; + if (inApp) { + await addSourceContext(newFrame); } - } - if (!sourceContext) { - return; - } + return newFrame; + }), + ); +} - const lines = sourceContext.split('\n'); - addContextToFrame(lines, frame); +/** + * Replaces the frames in the exception of a error. + * @param event Event + * @param frames StackFrame[] + */ +function replaceExceptionFramesInEvent(event: Event, frames: SentryStackFrame[]): void { + if (event.exception && event.exception.values && event.exception.values[0] && event.exception.values[0].stacktrace) { + event.exception.values[0].stacktrace.frames = frames.reverse(); } +} - /** - * Get source context for segment - */ - private async _fetchSourceContext(url: string, segments: Array, start: number): Promise { - return new Promise(resolve => { - const fullUrl = `${url}${segments.slice(start).join('/')}`; - - const xhr = createStealthXhr(); - if (!xhr) { - resolve(null); - return; - } +/** + * Replaces the frames in the thread of a message. + * @param event Event + * @param frames StackFrame[] + */ +function replaceThreadFramesInEvent(event: Event, frames: SentryStackFrame[]): void { + if (event.threads && event.threads.values && event.threads.values[0] && event.threads.values[0].stacktrace) { + event.threads.values[0].stacktrace.frames = frames.reverse(); + } +} - xhr.open('GET', fullUrl, true); - xhr.send(); +/** + * This tries to add source context for in_app Frames + * + * @param frame StackFrame + * @param getDevServer function from RN to get DevServer URL + */ +async function addSourceContext(frame: SentryStackFrame): Promise { + let sourceContext: string | null = null; - xhr.onreadystatechange = (): void => { - if (xhr.readyState === XHR_READYSTATE_DONE) { - if (xhr.status !== 200) { - resolve(null); - } - const response = xhr.responseText; - if ( - typeof response !== 'string' || - // Expo Dev Server responses with status 200 and config JSON - // when web support not enabled and requested file not found - response.startsWith('{') - ) { - resolve(null); - } + const segments = frame.filename?.split('/') ?? []; - resolve(response); - } - }; - xhr.onerror = (): void => { - resolve(null); - }; - }); + const serverUrl = getDevServer()?.url; + if (!serverUrl) { + return; } - /** - * Loads and calls RN Core Devtools parseErrorStack function. - */ - private _parseErrorStack(errorStack: string): Array { - if (!ReactNativeLibraries.Devtools) { - throw new Error('React Native Devtools not available.'); + for (const idx in segments) { + if (!Object.prototype.hasOwnProperty.call(segments, idx)) { + continue; } - return ReactNativeLibraries.Devtools.parseErrorStack(errorStack); - } - /** - * Loads and calls RN Core Devtools symbolicateStackTrace function. - */ - private _symbolicateStackTrace( - stack: Array, - extraData?: Record, - ): Promise { - if (!ReactNativeLibraries.Devtools) { - throw new Error('React Native Devtools not available.'); + sourceContext = await fetchSourceContext(serverUrl, segments, -idx); + if (sourceContext) { + break; } - return ReactNativeLibraries.Devtools.symbolicateStackTrace(stack, extraData); } - /** - * Loads and returns the RN DevServer URL. - */ - private _getDevServer(): ReactNative.DevServerInfo | undefined { - try { - return ReactNativeLibraries.Devtools?.getDevServer(); - } catch (_oO) { - // We can't load devserver URL - } - return undefined; + if (!sourceContext) { + return; } + + const lines = sourceContext.split('\n'); + addContextToFrame(lines, frame); } diff --git a/src/js/integrations/debugsymbolicatorutils.ts b/src/js/integrations/debugsymbolicatorutils.ts new file mode 100644 index 0000000000..8bef6de82c --- /dev/null +++ b/src/js/integrations/debugsymbolicatorutils.ts @@ -0,0 +1,78 @@ +import { ReactNativeLibraries } from '../utils/rnlibraries'; +import { createStealthXhr, XHR_READYSTATE_DONE } from '../utils/xhr'; +import type * as ReactNative from '../vendor/react-native'; + +/** + * Get source context for segment + */ +export async function fetchSourceContext(url: string, segments: Array, start: number): Promise { + return new Promise(resolve => { + const fullUrl = `${url}${segments.slice(start).join('/')}`; + + const xhr = createStealthXhr(); + if (!xhr) { + resolve(null); + return; + } + + xhr.open('GET', fullUrl, true); + xhr.send(); + + xhr.onreadystatechange = (): void => { + if (xhr.readyState === XHR_READYSTATE_DONE) { + if (xhr.status !== 200) { + resolve(null); + } + const response = xhr.responseText; + if ( + typeof response !== 'string' || + // Expo Dev Server responses with status 200 and config JSON + // when web support not enabled and requested file not found + response.startsWith('{') + ) { + resolve(null); + } + + resolve(response); + } + }; + xhr.onerror = (): void => { + resolve(null); + }; + }); +} + +/** + * Loads and calls RN Core Devtools parseErrorStack function. + */ +export function parseErrorStack(errorStack: string): Array { + if (!ReactNativeLibraries.Devtools) { + throw new Error('React Native Devtools not available.'); + } + return ReactNativeLibraries.Devtools.parseErrorStack(errorStack); +} + +/** + * Loads and calls RN Core Devtools symbolicateStackTrace function. + */ +export function symbolicateStackTrace( + stack: Array, + extraData?: Record, +): Promise { + if (!ReactNativeLibraries.Devtools) { + throw new Error('React Native Devtools not available.'); + } + return ReactNativeLibraries.Devtools.symbolicateStackTrace(stack, extraData); +} + +/** + * Loads and returns the RN DevServer URL. + */ +export function getDevServer(): ReactNative.DevServerInfo | undefined { + try { + return ReactNativeLibraries.Devtools?.getDevServer(); + } catch (_oO) { + // We can't load devserver URL + } + return undefined; +} diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index 4dc16bfae1..877c14168b 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -1,25 +1,34 @@ -import { HttpClient } from '@sentry/integrations'; -import { Integrations as BrowserReactIntegrations } from '@sentry/react'; +import { httpClientIntegration } from '@sentry/integrations'; +import { + breadcrumbsIntegration, + browserApiErrorsIntegration, + dedupeIntegration, + functionToStringIntegration, + globalHandlersIntegration as browserGlobalHandlersIntegration, + httpContextIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration as browserLinkedErrorsIntegration, +} from '@sentry/react'; import type { Integration } from '@sentry/types'; import type { ReactNativeClientOptions } from '../options'; import { HermesProfiling } from '../profiling/integration'; import { ReactNativeTracing } from '../tracing'; import { isExpoGo, notWeb } from '../utils/environment'; -import { DebugSymbolicator } from './debugsymbolicator'; -import { DeviceContext } from './devicecontext'; -import { EventOrigin } from './eventorigin'; -import { ExpoContext } from './expocontext'; -import { ModulesLoader } from './modulesloader'; -import { NativeLinkedErrors } from './nativelinkederrors'; -import { ReactNativeErrorHandlers } from './reactnativeerrorhandlers'; -import { ReactNativeInfo } from './reactnativeinfo'; -import { Release } from './release'; +import { debugSymbolicatorIntegration } from './debugsymbolicator'; +import { deviceContextIntegration } from './devicecontext'; +import { eventOriginIntegration } from './eventorigin'; +import { expoContextIntegration } from './expocontext'; +import { modulesLoaderIntegration } from './modulesloader'; +import { nativeLinkedErrorsIntegration } from './nativelinkederrors'; +import { reactNativeErrorHandlersIntegration } from './reactnativeerrorhandlers'; +import { reactNativeInfoIntegration } from './reactnativeinfo'; +import { nativeReleaseIntegration } from './release'; import { createReactNativeRewriteFrames } from './rewriteframes'; -import { Screenshot } from './screenshot'; -import { SdkInfo } from './sdkinfo'; +import { screenshotIntegration } from './screenshot'; +import { sdkInfoIntegration } from './sdkinfo'; import { Spotlight } from './spotlight'; -import { ViewHierarchy } from './viewhierarchy'; +import { viewHierarchyIntegration } from './viewhierarchy'; /** * Returns the default ReactNative integrations based on the current environment. @@ -33,44 +42,44 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ if (notWeb()) { integrations.push( - new ReactNativeErrorHandlers({ + reactNativeErrorHandlersIntegration({ patchGlobalPromise: options.patchGlobalPromise, }), ); - integrations.push(new NativeLinkedErrors()); + integrations.push(nativeLinkedErrorsIntegration()); } else { - integrations.push(new BrowserReactIntegrations.TryCatch()); - integrations.push(new BrowserReactIntegrations.GlobalHandlers()); - integrations.push(new BrowserReactIntegrations.LinkedErrors()); + integrations.push(browserApiErrorsIntegration()); + integrations.push(browserGlobalHandlersIntegration()); + integrations.push(browserLinkedErrorsIntegration()); } // @sentry/react default integrations - integrations.push(new BrowserReactIntegrations.InboundFilters()); - integrations.push(new BrowserReactIntegrations.FunctionToString()); - integrations.push(new BrowserReactIntegrations.Breadcrumbs()); - integrations.push(new BrowserReactIntegrations.Dedupe()); - integrations.push(new BrowserReactIntegrations.HttpContext()); + integrations.push(inboundFiltersIntegration()); + integrations.push(functionToStringIntegration()); + integrations.push(breadcrumbsIntegration()); + integrations.push(dedupeIntegration()); + integrations.push(httpContextIntegration()); // end @sentry/react-native default integrations - integrations.push(new Release()); - integrations.push(new EventOrigin()); - integrations.push(new SdkInfo()); - integrations.push(new ReactNativeInfo()); + integrations.push(nativeReleaseIntegration()); + integrations.push(eventOriginIntegration()); + integrations.push(sdkInfoIntegration()); + integrations.push(reactNativeInfoIntegration()); if (__DEV__ && notWeb()) { - integrations.push(new DebugSymbolicator()); + integrations.push(debugSymbolicatorIntegration()); } integrations.push(createReactNativeRewriteFrames()); if (options.enableNative) { - integrations.push(new DeviceContext()); - integrations.push(new ModulesLoader()); + integrations.push(deviceContextIntegration()); + integrations.push(modulesLoaderIntegration()); if (options.attachScreenshot) { - integrations.push(new Screenshot()); + integrations.push(screenshotIntegration()); } if (options.attachViewHierarchy) { - integrations.push(new ViewHierarchy()); + integrations.push(viewHierarchyIntegration()); } if (options._experiments && typeof options._experiments.profilesSampleRate === 'number') { integrations.push(new HermesProfiling()); @@ -88,11 +97,11 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(new ReactNativeTracing()); } if (options.enableCaptureFailedRequests) { - integrations.push(new HttpClient()); + integrations.push(httpClientIntegration()); } if (isExpoGo()) { - integrations.push(new ExpoContext()); + integrations.push(expoContextIntegration()); } if (options.enableSpotlight) { diff --git a/src/js/integrations/devicecontext.ts b/src/js/integrations/devicecontext.ts index df2834a727..801e4d1c5d 100644 --- a/src/js/integrations/devicecontext.ts +++ b/src/js/integrations/devicecontext.ts @@ -1,5 +1,5 @@ /* eslint-disable complexity */ -import type { Event, EventProcessor, Hub, Integration } from '@sentry/types'; +import type { Event, IntegrationFn } from '@sentry/types'; import { logger, severityLevelFromString } from '@sentry/utils'; import { AppState } from 'react-native'; @@ -8,93 +8,81 @@ import type { NativeDeviceContextsResponse } from '../NativeRNSentry'; import { NATIVE } from '../wrapper'; /** Load device context from native. */ -export class DeviceContext implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'DeviceContext'; +export const deviceContextIntegration: IntegrationFn = () => { + return { + name: 'DeviceContext', + setupOnce: () => { + /* noop */ + }, + processEvent, + }; +}; - /** - * @inheritDoc - */ - public name: string = DeviceContext.id; - - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - addGlobalEventProcessor(async (event: Event) => { - const self = getCurrentHub().getIntegration(DeviceContext); - if (!self) { - return event; - } - - let native: NativeDeviceContextsResponse | null = null; - try { - native = await NATIVE.fetchNativeDeviceContexts(); - } catch (e) { - logger.log(`Failed to get device context from native: ${e}`); - } - - if (!native) { - return event; - } +async function processEvent(event: Event): Promise { + let native: NativeDeviceContextsResponse | null = null; + try { + native = await NATIVE.fetchNativeDeviceContexts(); + } catch (e) { + logger.log(`Failed to get device context from native: ${e}`); + } - const nativeUser = native.user; - if (!event.user && nativeUser) { - event.user = nativeUser; - } + if (!native) { + return event; + } - let nativeContexts = native.contexts; - if (AppState.currentState !== 'unknown') { - nativeContexts = nativeContexts || {}; - nativeContexts.app = { - ...nativeContexts.app, - in_foreground: AppState.currentState === 'active', - }; - } - if (nativeContexts) { - event.contexts = { ...nativeContexts, ...event.contexts }; - if (nativeContexts.app) { - event.contexts.app = { ...nativeContexts.app, ...event.contexts.app }; - } - } + const nativeUser = native.user; + if (!event.user && nativeUser) { + event.user = nativeUser; + } - const nativeTags = native.tags; - if (nativeTags) { - event.tags = { ...nativeTags, ...event.tags }; - } + let nativeContexts = native.contexts; + if (AppState.currentState !== 'unknown') { + nativeContexts = nativeContexts || {}; + nativeContexts.app = { + ...nativeContexts.app, + in_foreground: AppState.currentState === 'active', + }; + } + if (nativeContexts) { + event.contexts = { ...nativeContexts, ...event.contexts }; + if (nativeContexts.app) { + event.contexts.app = { ...nativeContexts.app, ...event.contexts.app }; + } + } - const nativeExtra = native.extra; - if (nativeExtra) { - event.extra = { ...nativeExtra, ...event.extra }; - } + const nativeTags = native.tags; + if (nativeTags) { + event.tags = { ...nativeTags, ...event.tags }; + } - const nativeFingerprint = native.fingerprint; - if (nativeFingerprint) { - event.fingerprint = (event.fingerprint ?? []).concat( - nativeFingerprint.filter(item => (event.fingerprint ?? []).indexOf(item) < 0), - ); - } + const nativeExtra = native.extra; + if (nativeExtra) { + event.extra = { ...nativeExtra, ...event.extra }; + } - const nativeLevel = typeof native['level'] === 'string' ? severityLevelFromString(native['level']) : undefined; - if (!event.level && nativeLevel) { - event.level = nativeLevel; - } + const nativeFingerprint = native.fingerprint; + if (nativeFingerprint) { + event.fingerprint = (event.fingerprint ?? []).concat( + nativeFingerprint.filter(item => (event.fingerprint ?? []).indexOf(item) < 0), + ); + } - const nativeEnvironment = native['environment']; - if (!event.environment && nativeEnvironment) { - event.environment = nativeEnvironment; - } + const nativeLevel = typeof native['level'] === 'string' ? severityLevelFromString(native['level']) : undefined; + if (!event.level && nativeLevel) { + event.level = nativeLevel; + } - const nativeBreadcrumbs = Array.isArray(native['breadcrumbs']) - ? native['breadcrumbs'].map(breadcrumbFromObject) - : undefined; - if (nativeBreadcrumbs) { - event.breadcrumbs = nativeBreadcrumbs; - } + const nativeEnvironment = native['environment']; + if (!event.environment && nativeEnvironment) { + event.environment = nativeEnvironment; + } - return event; - }); + const nativeBreadcrumbs = Array.isArray(native['breadcrumbs']) + ? native['breadcrumbs'].map(breadcrumbFromObject) + : undefined; + if (nativeBreadcrumbs) { + event.breadcrumbs = nativeBreadcrumbs; } + + return event; } diff --git a/src/js/integrations/eventorigin.ts b/src/js/integrations/eventorigin.ts index 3b61e562f1..e066432036 100644 --- a/src/js/integrations/eventorigin.ts +++ b/src/js/integrations/eventorigin.ts @@ -1,28 +1,19 @@ -import type { EventProcessor, Integration } from '@sentry/types'; +import type { Event, Integration } from '@sentry/types'; /** Default EventOrigin instrumentation */ -export class EventOrigin implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'EventOrigin'; - - /** - * @inheritDoc - */ - public name: string = EventOrigin.id; - - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { - addGlobalEventProcessor(event => { +export const eventOriginIntegration = (): Integration => { + return { + name: 'EventOrigin', + setupOnce: () => { + // noop + }, + processEvent: (event: Event) => { event.tags = event.tags ?? {}; event.tags['event.origin'] = 'javascript'; event.tags['event.environment'] = 'javascript'; return event; - }); - } -} + }, + }; +}; diff --git a/src/js/integrations/expocontext.ts b/src/js/integrations/expocontext.ts index 04944b53bc..b28b8a5e9b 100644 --- a/src/js/integrations/expocontext.ts +++ b/src/js/integrations/expocontext.ts @@ -1,44 +1,32 @@ -import type { DeviceContext, Event, EventProcessor, Hub, Integration, OsContext } from '@sentry/types'; +import type { DeviceContext, Event, Integration, OsContext } from '@sentry/types'; import { getExpoDevice } from '../utils/expomodules'; /** Load device context from expo modules. */ -export class ExpoContext implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'ExpoContext'; - - /** - * @inheritDoc - */ - public name: string = ExpoContext.id; - - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - addGlobalEventProcessor(async (event: Event) => { - const self = getCurrentHub().getIntegration(ExpoContext); - if (!self) { - return event; - } - - const expoDeviceContext = getExpoDeviceContext(); - if (expoDeviceContext) { - event.contexts = event.contexts || {}; - event.contexts.device = { ...expoDeviceContext, ...event.contexts.device }; - } +export const expoContextIntegration = (): Integration => { + return { + name: 'ExpoContext', + setupOnce: () => { + // noop + }, + processEvent, + }; +}; - const expoOsContext = getExpoOsContext(); - if (expoOsContext) { - event.contexts = event.contexts || {}; - event.contexts.os = { ...expoOsContext, ...event.contexts.os }; - } +function processEvent(event: Event): Event { + const expoDeviceContext = getExpoDeviceContext(); + if (expoDeviceContext) { + event.contexts = event.contexts || {}; + event.contexts.device = { ...expoDeviceContext, ...event.contexts.device }; + } - return event; - }); + const expoOsContext = getExpoOsContext(); + if (expoOsContext) { + event.contexts = event.contexts || {}; + event.contexts.os = { ...expoOsContext, ...event.contexts.os }; } + + return event; } /** diff --git a/src/js/integrations/index.ts b/src/js/integrations/index.ts index 3a8ad303ae..37772e6927 100644 --- a/src/js/integrations/index.ts +++ b/src/js/integrations/index.ts @@ -1,10 +1,14 @@ -export { DebugSymbolicator } from './debugsymbolicator'; -export { DeviceContext } from './devicecontext'; -export { ReactNativeErrorHandlers } from './reactnativeerrorhandlers'; -export { Release } from './release'; -export { EventOrigin } from './eventorigin'; -export { SdkInfo } from './sdkinfo'; -export { ReactNativeInfo } from './reactnativeinfo'; -export { ModulesLoader } from './modulesloader'; +export { debugSymbolicatorIntegration } from './debugsymbolicator'; +export { deviceContextIntegration } from './devicecontext'; +export { reactNativeErrorHandlersIntegration } from './reactnativeerrorhandlers'; +export { nativeLinkedErrorsIntegration } from './nativelinkederrors'; +export { nativeReleaseIntegration } from './release'; +export { eventOriginIntegration } from './eventorigin'; +export { sdkInfoIntegration } from './sdkinfo'; +export { reactNativeInfoIntegration } from './reactnativeinfo'; +export { modulesLoaderIntegration } from './modulesloader'; export { HermesProfiling } from '../profiling/integration'; +export { screenshotIntegration } from './screenshot'; +export { viewHierarchyIntegration } from './viewhierarchy'; +export { expoContextIntegration } from './expocontext'; export { Spotlight } from './spotlight'; diff --git a/src/js/integrations/modulesloader.ts b/src/js/integrations/modulesloader.ts index b3f4da04cc..6bc25a33f5 100644 --- a/src/js/integrations/modulesloader.ts +++ b/src/js/integrations/modulesloader.ts @@ -1,43 +1,38 @@ -import type { Event, EventProcessor, Integration } from '@sentry/types'; +import type { Event, Integration } from '@sentry/types'; import { logger } from '@sentry/utils'; import { NATIVE } from '../wrapper'; /** Loads runtime JS modules from prepared file. */ -export class ModulesLoader implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'ModulesLoader'; +export const modulesLoaderIntegration = (): Integration => { + return { + name: 'ModulesLoader', + setupOnce: () => { + // noop + }, + processEvent: createProcessEvent(), + }; +}; - /** - * @inheritDoc - */ - public name: string = ModulesLoader.id; +function createProcessEvent(): (event: Event) => Promise { + let isSetup = false; + let modules: Record | null = null; - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { - let isSetup = false; - let modules: Record | null; - - addGlobalEventProcessor(async (event: Event) => { - if (!isSetup) { - try { - modules = await NATIVE.fetchModules(); - } catch (e) { - logger.log(`Failed to get modules from native: ${e}`); - } - isSetup = true; - } - if (modules) { - event.modules = { - ...modules, - ...event.modules, - }; + return async (event: Event) => { + if (!isSetup) { + try { + modules = await NATIVE.fetchModules(); + } catch (e) { + logger.log(`Failed to get modules from native: ${e}`); } - return event; - }); - } + isSetup = true; + } + if (modules) { + event.modules = { + ...modules, + ...event.modules, + }; + } + return event; + }; } diff --git a/src/js/integrations/nativelinkederrors.ts b/src/js/integrations/nativelinkederrors.ts index 8f4f5e0566..41d62f07dd 100644 --- a/src/js/integrations/nativelinkederrors.ts +++ b/src/js/integrations/nativelinkederrors.ts @@ -4,10 +4,8 @@ import type { DebugImage, Event, EventHint, - EventProcessor, Exception, ExtendedError, - Hub, Integration, StackFrame, StackParser, @@ -28,197 +26,171 @@ interface LinkedErrorsOptions { /** * Processes JS and RN native linked errors. */ -export class NativeLinkedErrors implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'NativeLinkedErrors'; - - /** - * @inheritDoc - */ - public name: string = NativeLinkedErrors.id; - - private readonly _key: LinkedErrorsOptions['key']; - private readonly _limit: LinkedErrorsOptions['limit']; - private _nativePackage: string | null = null; - - /** - * @inheritDoc - */ - public constructor(options: Partial = {}) { - this._key = options.key || DEFAULT_KEY; - this._limit = options.limit || DEFAULT_LIMIT; +export const nativeLinkedErrorsIntegration = (options: Partial = {}): Integration => { + const key = options.key || DEFAULT_KEY; + const limit = options.limit || DEFAULT_LIMIT; + + return { + name: 'NativeLinkedErrors', + setupOnce: (): void => { + // noop + }, + preprocessEvent: (event: Event, hint: EventHint, client: Client): void => + preprocessEvent(event, hint, client, limit, key), + }; +}; + +function preprocessEvent(event: Event, hint: EventHint | undefined, client: Client, limit: number, key: string): void { + if (!event.exception || !event.exception.values || !hint || !isInstanceOf(hint.originalException, Error)) { + return; } - /** - * @inheritDoc - */ - public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, _getCurrentHub: () => Hub): void { - /* noop */ - } - - /** - * @inheritDoc - */ - public preprocessEvent(event: Event, hint: EventHint | undefined, client: Client): void { - if (this._nativePackage === null) { - this._nativePackage = this._fetchNativePackage(); - } + const parser = client.getOptions().stackParser; - this._handler(client.getOptions().stackParser, this._key, this._limit, event, hint); - } + const { exceptions: linkedErrors, debugImages } = walkErrorTree( + parser, + limit, + hint.originalException as ExtendedError, + key, + ); + event.exception.values = [...event.exception.values, ...linkedErrors]; - /** - * Enriches passed event with linked exceptions and native debug meta images. - */ - private _handler(parser: StackParser, key: string, limit: number, event: Event, hint?: EventHint): void { - if (!event.exception || !event.exception.values || !hint || !isInstanceOf(hint.originalException, Error)) { - return; - } - const { exceptions: linkedErrors, debugImages } = this._walkErrorTree( - parser, - limit, - hint.originalException as ExtendedError, - key, - ); - event.exception.values = [...event.exception.values, ...linkedErrors]; - - event.debug_meta = event.debug_meta || {}; - event.debug_meta.images = event.debug_meta.images || []; - event.debug_meta.images.push(...(debugImages || [])); - } + event.debug_meta = event.debug_meta || {}; + event.debug_meta.images = event.debug_meta.images || []; + event.debug_meta.images.push(...(debugImages || [])); +} - /** - * Walks linked errors and created Sentry exceptions chain. - * Collects debug images from native errors stack frames. - */ - private _walkErrorTree( - parser: StackParser, - limit: number, - error: ExtendedError, - key: string, - exceptions: Exception[] = [], - debugImages: DebugImage[] = [], - ): { - exceptions: Exception[]; - debugImages?: DebugImage[]; - } { - const linkedError = error[key]; - if (!linkedError || exceptions.length + 1 >= limit) { - return { - exceptions, - debugImages, - }; - } - - let exception: Exception; - let exceptionDebugImages: DebugImage[] | undefined; - if ('stackElements' in linkedError) { - // isJavaException - exception = this._exceptionFromJavaStackElements(linkedError); - } else if ('stackReturnAddresses' in linkedError) { - // isObjCException - const { appleException, appleDebugImages } = this._exceptionFromAppleStackReturnAddresses(linkedError); - exception = appleException; - exceptionDebugImages = appleDebugImages; - } else if (isInstanceOf(linkedError, Error)) { - exception = exceptionFromError(parser, error[key]); - } else if (isPlainObject(linkedError)) { - exception = { - type: typeof linkedError.name === 'string' ? linkedError.name : undefined, - value: typeof linkedError.message === 'string' ? linkedError.message : undefined, - }; - } else { - return { - exceptions, - debugImages, - }; - } - - return this._walkErrorTree( - parser, - limit, - linkedError, - key, - [...exceptions, exception], - [...debugImages, ...(exceptionDebugImages || [])], - ); +/** + * Walks linked errors and created Sentry exceptions chain. + * Collects debug images from native errors stack frames. + */ +function walkErrorTree( + parser: StackParser, + limit: number, + error: ExtendedError, + key: string, + exceptions: Exception[] = [], + debugImages: DebugImage[] = [], +): { + exceptions: Exception[]; + debugImages?: DebugImage[]; +} { + const linkedError = error[key]; + if (!linkedError || exceptions.length + 1 >= limit) { + return { + exceptions, + debugImages, + }; } - /** - * Converts a Java Throwable to an SentryException - */ - private _exceptionFromJavaStackElements(javaThrowable: { - name: string; - message: string; - stackElements: { - className: string; - fileName: string; - methodName: string; - lineNumber: number; - }[]; - }): Exception { + let exception: Exception; + let exceptionDebugImages: DebugImage[] | undefined; + if ('stackElements' in linkedError) { + // isJavaException + exception = exceptionFromJavaStackElements(linkedError); + } else if ('stackReturnAddresses' in linkedError) { + // isObjCException + const { appleException, appleDebugImages } = exceptionFromAppleStackReturnAddresses(linkedError); + exception = appleException; + exceptionDebugImages = appleDebugImages; + } else if (isInstanceOf(linkedError, Error)) { + exception = exceptionFromError(parser, error[key]); + } else if (isPlainObject(linkedError)) { + exception = { + type: typeof linkedError.name === 'string' ? linkedError.name : undefined, + value: typeof linkedError.message === 'string' ? linkedError.message : undefined, + }; + } else { return { - type: javaThrowable.name, - value: javaThrowable.message, - stacktrace: { - frames: javaThrowable.stackElements - .map( - stackElement => - { - platform: 'java', - module: stackElement.className, - filename: stackElement.fileName, - lineno: stackElement.lineNumber >= 0 ? stackElement.lineNumber : undefined, - function: stackElement.methodName, - in_app: - this._nativePackage !== null && stackElement.className.startsWith(this._nativePackage) - ? true - : undefined, - }, - ) - .reverse(), - }, + exceptions, + debugImages, }; } - /** - * Converts StackAddresses to a SentryException with DebugMetaImages - */ - private _exceptionFromAppleStackReturnAddresses(objCException: { - name: string; - message: string; - stackReturnAddresses: number[]; - }): { - appleException: Exception; - appleDebugImages: DebugImage[]; - } { - const nativeStackFrames = this._fetchNativeStackFrames(objCException.stackReturnAddresses); + return walkErrorTree( + parser, + limit, + linkedError, + key, + [...exceptions, exception], + [...debugImages, ...(exceptionDebugImages || [])], + ); +} - return { - appleException: { - type: objCException.name, - value: objCException.message, - stacktrace: { - frames: (nativeStackFrames && nativeStackFrames.frames.reverse()) || [], - }, +/** + * Converts a Java Throwable to an SentryException + */ +function exceptionFromJavaStackElements(javaThrowable: { + name: string; + message: string; + stackElements: { + className: string; + fileName: string; + methodName: string; + lineNumber: number; + }[]; +}): Exception { + const nativePackage = fetchNativePackage(); + return { + type: javaThrowable.name, + value: javaThrowable.message, + stacktrace: { + frames: javaThrowable.stackElements + .map( + stackElement => + { + platform: 'java', + module: stackElement.className, + filename: stackElement.fileName, + lineno: stackElement.lineNumber >= 0 ? stackElement.lineNumber : undefined, + function: stackElement.methodName, + in_app: nativePackage !== null && stackElement.className.startsWith(nativePackage) ? true : undefined, + }, + ) + .reverse(), + }, + }; +} + +/** + * Converts StackAddresses to a SentryException with DebugMetaImages + */ +function exceptionFromAppleStackReturnAddresses(objCException: { + name: string; + message: string; + stackReturnAddresses: number[]; +}): { + appleException: Exception; + appleDebugImages: DebugImage[]; +} { + const nativeStackFrames = fetchNativeStackFrames(objCException.stackReturnAddresses); + + return { + appleException: { + type: objCException.name, + value: objCException.message, + stacktrace: { + frames: (nativeStackFrames && nativeStackFrames.frames.reverse()) || [], }, - appleDebugImages: (nativeStackFrames && (nativeStackFrames.debugMetaImages as DebugImage[])) || [], - }; - } + }, + appleDebugImages: (nativeStackFrames && (nativeStackFrames.debugMetaImages as DebugImage[])) || [], + }; +} - /** - * Fetches the native package/image name from the native layer - */ - private _fetchNativePackage(): string | null { - return NATIVE.fetchNativePackageName(); +let nativePackage: string | null = null; +/** + * Fetches the native package/image name from the native layer + */ +function fetchNativePackage(): string | null { + if (nativePackage === null) { + nativePackage = NATIVE.fetchNativePackageName(); } + return nativePackage; +} - /** - * Fetches native debug image information on iOS - */ - private _fetchNativeStackFrames(instructionsAddr: number[]): NativeStackFrames | null { - return NATIVE.fetchNativeStackFramesBy(instructionsAddr); - } +/** + * Fetches native debug image information on iOS + */ +function fetchNativeStackFrames(instructionsAddr: number[]): NativeStackFrames | null { + return NATIVE.fetchNativeStackFramesBy(instructionsAddr); } diff --git a/src/js/integrations/reactnativeerrorhandlers.ts b/src/js/integrations/reactnativeerrorhandlers.ts index 934c201ad7..3b95729b54 100644 --- a/src/js/integrations/reactnativeerrorhandlers.ts +++ b/src/js/integrations/reactnativeerrorhandlers.ts @@ -1,11 +1,10 @@ -import { getCurrentHub } from '@sentry/core'; +import { captureException, getClient, getCurrentScope } from '@sentry/core'; import type { EventHint, Integration, SeverityLevel } from '@sentry/types'; import { addExceptionMechanism, logger } from '@sentry/utils'; -import type { ReactNativeClient } from '../client'; import { createSyntheticError, isErrorLike } from '../utils/error'; -import { ReactNativeLibraries } from '../utils/rnlibraries'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; +import { checkPromiseAndWarn, polyfillPromise, requireRejectionTracking } from './reactnativeerrorhandlersutils'; /** ReactNativeErrorHandlers Options */ interface ReactNativeErrorHandlersOptions { @@ -20,255 +19,146 @@ interface PromiseRejectionTrackingOptions { } /** ReactNativeErrorHandlers Integration */ -export class ReactNativeErrorHandlers implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'ReactNativeErrorHandlers'; - - /** - * @inheritDoc - */ - public name: string = ReactNativeErrorHandlers.id; - - /** ReactNativeOptions */ - private readonly _options: ReactNativeErrorHandlersOptions; +export const reactNativeErrorHandlersIntegration = ( + options: Partial = {}, +): Integration => { + return { + name: 'ReactNativeErrorHandlers', + setupOnce: () => + setup({ + onerror: options.onerror || true, + onunhandledrejection: options.onunhandledrejection || true, + patchGlobalPromise: options.patchGlobalPromise || true, + }), + }; +}; + +function setup(options: ReactNativeErrorHandlersOptions): void { + options.onunhandledrejection && setupUnhandledRejectionsTracking(options.patchGlobalPromise); + options.onerror && setupErrorUtilsGlobalHandler(); +} - /** Constructor */ - public constructor(options?: Partial) { - this._options = { - onerror: true, - onunhandledrejection: true, - patchGlobalPromise: true, - ...options, - }; +/** + * Setup unhandled promise rejection tracking + */ +function setupUnhandledRejectionsTracking(patchGlobalPromise: boolean): void { + if (patchGlobalPromise) { + polyfillPromise(); } - /** - * @inheritDoc - */ - public setupOnce(): void { - this._handleUnhandledRejections(); - this._handleOnError(); - } + attachUnhandledRejectionHandler(); + checkPromiseAndWarn(); +} - /** - * Handle Promises - */ - private _handleUnhandledRejections(): void { - if (this._options.onunhandledrejection) { - if (this._options.patchGlobalPromise) { - this._polyfillPromise(); +function attachUnhandledRejectionHandler(): void { + const tracking = requireRejectionTracking(); + + const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = { + onUnhandled: (id, rejection = {}) => { + // eslint-disable-next-line no-console + console.warn(`Possible Unhandled Promise Rejection (id: ${id}):\n${rejection}`); + }, + onHandled: id => { + // eslint-disable-next-line no-console + console.warn( + `Promise Rejection Handled (id: ${id})\n` + + 'This means you can ignore any previous messages of the form ' + + `"Possible Unhandled Promise Rejection (id: ${id}):"`, + ); + }, + }; + + tracking.enable({ + allRejections: true, + onUnhandled: (id: string, error: unknown) => { + if (__DEV__) { + promiseRejectionTrackingOptions.onUnhandled(id, error); } - this._attachUnhandledRejectionHandler(); - this._checkPromiseAndWarn(); - } - } - /** - * Polyfill the global promise instance with one we can be sure that we can attach the tracking to. - * - * In newer RN versions >=0.63, the global promise is not the same reference as the one imported from the promise library. - * This is due to a version mismatch between promise versions. - * Originally we tried a solution where we would have you put a package resolution to ensure the promise instances match. However, - * - Using a package resolution requires the you to manually troubleshoot. - * - The package resolution fix no longer works with 0.67 on iOS Hermes. - */ - private _polyfillPromise(): void { - if (!ReactNativeLibraries.Utilities) { - logger.warn('Could not polyfill Promise. React Native Libraries Utilities not found.'); - return; - } - - const Promise = this._getPromisePolyfill(); - - // As of RN 0.67 only done and finally are used - // eslint-disable-next-line import/no-extraneous-dependencies - require('promise/setimmediate/done'); - // eslint-disable-next-line import/no-extraneous-dependencies - require('promise/setimmediate/finally'); + captureException(error, { + data: { id }, + originalException: error, + syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), + }); + }, + onHandled: (id: string) => { + promiseRejectionTrackingOptions.onHandled(id); + }, + }); +} - ReactNativeLibraries.Utilities.polyfillGlobal('Promise', () => Promise); - } +function setupErrorUtilsGlobalHandler(): void { + let handlingFatal = false; - /** - * Single source of truth for the Promise implementation we want to use. - * This is important for verifying that the rejected promise tracing will work as expected. - */ - private _getPromisePolyfill(): unknown { - /* eslint-disable import/no-extraneous-dependencies,@typescript-eslint/no-var-requires */ - // Below, we follow the exact way React Native initializes its promise library, and we globally replace it. - return require('promise/setimmediate/es6-extensions'); + const errorUtils = RN_GLOBAL_OBJ.ErrorUtils; + if (!errorUtils) { + logger.warn('ErrorUtils not found. Can be caused by different environment for example react-native-web.'); + return; } - /** - * Attach the unhandled rejection handler - */ - private _attachUnhandledRejectionHandler(): void { - const tracking = this._loadRejectionTracking(); - - const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = { - onUnhandled: (id, rejection = {}) => { - // eslint-disable-next-line no-console - console.warn(`Possible Unhandled Promise Rejection (id: ${id}):\n${rejection}`); - }, - onHandled: id => { - // eslint-disable-next-line no-console - console.warn( - `Promise Rejection Handled (id: ${id})\n` + - 'This means you can ignore any previous messages of the form ' + - `"Possible Unhandled Promise Rejection (id: ${id}):"`, - ); - }, - }; - - tracking.enable({ - allRejections: true, - onUnhandled: (id: string, error: unknown) => { - if (__DEV__) { - promiseRejectionTrackingOptions.onUnhandled(id, error); - } + const defaultHandler = errorUtils.getGlobalHandler && errorUtils.getGlobalHandler(); - getCurrentHub().captureException(error, { - data: { id }, - originalException: error, - syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), - }); - }, - onHandled: (id: string) => { - promiseRejectionTrackingOptions.onHandled(id); - }, - }); - } - /** - * Checks if the promise is the same one or not, if not it will warn the user - */ - private _checkPromiseAndWarn(): void { - try { - // `promise` package is a dependency of react-native, therefore it is always available. - // but it is possible that the user has installed a different version of promise - // or dependency that uses a different version. - // We have to check if the React Native Promise and the `promise` package Promise are using the same reference. - // If they are not, likely there are multiple versions of the `promise` package installed. - const ReactNativePromise = ReactNativeLibraries.Promise; - // eslint-disable-next-line @typescript-eslint/no-var-requires,import/no-extraneous-dependencies - const PromisePackagePromise = require('promise/setimmediate/es6-extensions'); - const UsedPromisePolyfill = this._getPromisePolyfill(); - - if (ReactNativePromise !== PromisePackagePromise) { - logger.warn( - 'You appear to have multiple versions of the "promise" package installed. ' + - 'This may cause unexpected behavior like undefined `Promise.allSettled`. ' + - 'Please install the `promise` package manually using the exact version as the React Native package. ' + - 'See https://docs.sentry.io/platforms/react-native/troubleshooting/ for more details.', - ); - } - - // This only make sense if the user disabled the integration Polyfill - if (UsedPromisePolyfill !== RN_GLOBAL_OBJ.Promise) { - logger.warn( - 'Unhandled promise rejections will not be caught by Sentry. ' + - 'See https://docs.sentry.io/platforms/react-native/troubleshooting/ for more details.', - ); - } else { - logger.log('Unhandled promise rejections will be caught by Sentry.'); - } - } catch (e) { - // Do Nothing - logger.warn( - 'Unhandled promise rejections will not be caught by Sentry. ' + - 'See https://docs.sentry.io/platforms/react-native/troubleshooting/ for more details.', - ); - } - } - /** - * Handle errors - */ - private _handleOnError(): void { - if (this._options.onerror) { - let handlingFatal = false; - - const errorUtils = RN_GLOBAL_OBJ.ErrorUtils; - if (!errorUtils) { - logger.warn('ErrorUtils not found. Can be caused by different environment for example react-native-web.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errorUtils.setGlobalHandler(async (error: any, isFatal?: boolean) => { + // We want to handle fatals, but only in production mode. + const shouldHandleFatal = isFatal && !__DEV__; + if (shouldHandleFatal) { + if (handlingFatal) { + logger.log('Encountered multiple fatals in a row. The latest:', error); return; } + handlingFatal = true; + } - const defaultHandler = errorUtils.getGlobalHandler && errorUtils.getGlobalHandler(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - errorUtils.setGlobalHandler(async (error: any, isFatal?: boolean) => { - // We want to handle fatals, but only in production mode. - const shouldHandleFatal = isFatal && !__DEV__; - if (shouldHandleFatal) { - if (handlingFatal) { - logger.log('Encountered multiple fatals in a row. The latest:', error); - return; - } - handlingFatal = true; - } - - const currentHub = getCurrentHub(); - const client = currentHub.getClient(); - const scope = currentHub.getScope(); + const client = getClient(); - if (!client) { - logger.error('Sentry client is missing, the error event might be lost.', error); + if (!client) { + logger.error('Sentry client is missing, the error event might be lost.', error); - // If there is no client something is fishy, anyway we call the default handler - defaultHandler(error, isFatal); + // If there is no client something is fishy, anyway we call the default handler + defaultHandler(error, isFatal); - return; - } + return; + } - const options = client.getOptions(); + const hint: EventHint = { + originalException: error, + attachments: getCurrentScope().getScopeData().attachments, + }; + const event = await client.eventFromException(error, hint); - const hint: EventHint = { - originalException: error, - attachments: scope?.getAttachments(), - }; - const event = await client.eventFromException(error, hint); + if (isFatal) { + event.level = 'fatal' as SeverityLevel; - if (isFatal) { - event.level = 'fatal' as SeverityLevel; + addExceptionMechanism(event, { + handled: false, + type: 'onerror', + }); + } else { + event.level = 'error'; - addExceptionMechanism(event, { - handled: false, - type: 'onerror', - }); - } + addExceptionMechanism(event, { + handled: true, + type: 'generic', + }); + } - currentHub.captureEvent(event, hint); + client.captureEvent(event, hint); - if (!__DEV__) { - void client.flush(options.shutdownTimeout || 2000).then( - () => { - defaultHandler(error, isFatal); - }, - (reason: unknown) => { - logger.error( - '[ReactNativeErrorHandlers] Error while flushing the event cache after uncaught error.', - reason, - ); - }, - ); - } else { - // If in dev, we call the default handler anyway and hope the error will be sent - // Just for a better dev experience - defaultHandler(error, isFatal); - } - }); + if (__DEV__) { + // If in dev, we call the default handler anyway and hope the error will be sent + // Just for a better dev experience + defaultHandler(error, isFatal); + return; } - } - /** - * Loads and returns rejection tracking module - */ - private _loadRejectionTracking(): { - disable: () => void; - enable: (arg: unknown) => void; - } { - // eslint-disable-next-line @typescript-eslint/no-var-requires,import/no-extraneous-dependencies - return require('promise/setimmediate/rejection-tracking'); - } + void client.flush(client.getOptions().shutdownTimeout || 2000).then( + () => { + defaultHandler(error, isFatal); + }, + (reason: unknown) => { + logger.error('[ReactNativeErrorHandlers] Error while flushing the event cache after uncaught error.', reason); + }, + ); + }); } diff --git a/src/js/integrations/reactnativeerrorhandlersutils.ts b/src/js/integrations/reactnativeerrorhandlersutils.ts new file mode 100644 index 0000000000..835f77b719 --- /dev/null +++ b/src/js/integrations/reactnativeerrorhandlersutils.ts @@ -0,0 +1,93 @@ +import { logger } from '@sentry/utils'; + +import { ReactNativeLibraries } from '../utils/rnlibraries'; +import { RN_GLOBAL_OBJ } from '../utils/worldwide'; + +/** + * Polyfill the global promise instance with one we can be sure that we can attach the tracking to. + * + * In newer RN versions >=0.63, the global promise is not the same reference as the one imported from the promise library. + * This is due to a version mismatch between promise versions. + * Originally we tried a solution where we would have you put a package resolution to ensure the promise instances match. However, + * - Using a package resolution requires the you to manually troubleshoot. + * - The package resolution fix no longer works with 0.67 on iOS Hermes. + */ +export function polyfillPromise(): void { + if (!ReactNativeLibraries.Utilities) { + logger.warn('Could not polyfill Promise. React Native Libraries Utilities not found.'); + return; + } + + const Promise = getPromisePolyfill(); + + // As of RN 0.67 only done and finally are used + // eslint-disable-next-line import/no-extraneous-dependencies + require('promise/setimmediate/done'); + // eslint-disable-next-line import/no-extraneous-dependencies + require('promise/setimmediate/finally'); + + ReactNativeLibraries.Utilities.polyfillGlobal('Promise', () => Promise); +} + +/** + * Single source of truth for the Promise implementation we want to use. + * This is important for verifying that the rejected promise tracing will work as expected. + */ +export function getPromisePolyfill(): unknown { + /* eslint-disable import/no-extraneous-dependencies,@typescript-eslint/no-var-requires */ + // Below, we follow the exact way React Native initializes its promise library, and we globally replace it. + return require('promise/setimmediate/es6-extensions'); +} + +/** + * Lazy require the rejection tracking module + */ +export function requireRejectionTracking(): { + disable: () => void; + enable: (arg: unknown) => void; +} { + // eslint-disable-next-line @typescript-eslint/no-var-requires,import/no-extraneous-dependencies + return require('promise/setimmediate/rejection-tracking'); +} + +/** + * Checks if the promise is the same one or not, if not it will warn the user + */ +export function checkPromiseAndWarn(): void { + try { + // `promise` package is a dependency of react-native, therefore it is always available. + // but it is possible that the user has installed a different version of promise + // or dependency that uses a different version. + // We have to check if the React Native Promise and the `promise` package Promise are using the same reference. + // If they are not, likely there are multiple versions of the `promise` package installed. + const ReactNativePromise = ReactNativeLibraries.Promise; + // eslint-disable-next-line @typescript-eslint/no-var-requires,import/no-extraneous-dependencies + const PromisePackagePromise = require('promise/setimmediate/es6-extensions'); + const UsedPromisePolyfill = getPromisePolyfill(); + + if (ReactNativePromise !== PromisePackagePromise) { + logger.warn( + 'You appear to have multiple versions of the "promise" package installed. ' + + 'This may cause unexpected behavior like undefined `Promise.allSettled`. ' + + 'Please install the `promise` package manually using the exact version as the React Native package. ' + + 'See https://docs.sentry.io/platforms/react-native/troubleshooting/ for more details.', + ); + } + + // This only make sense if the user disabled the integration Polyfill + if (UsedPromisePolyfill !== RN_GLOBAL_OBJ.Promise) { + logger.warn( + 'Unhandled promise rejections will not be caught by Sentry. ' + + 'See https://docs.sentry.io/platforms/react-native/troubleshooting/ for more details.', + ); + } else { + logger.log('Unhandled promise rejections will be caught by Sentry.'); + } + } catch (e) { + // Do Nothing + logger.warn( + 'Unhandled promise rejections will not be caught by Sentry. ' + + 'See https://docs.sentry.io/platforms/react-native/troubleshooting/ for more details.', + ); + } +} diff --git a/src/js/integrations/reactnativeinfo.ts b/src/js/integrations/reactnativeinfo.ts index dc03ccac42..73e05c488e 100644 --- a/src/js/integrations/reactnativeinfo.ts +++ b/src/js/integrations/reactnativeinfo.ts @@ -1,4 +1,4 @@ -import type { Context, Event, EventHint, EventProcessor, Integration } from '@sentry/types'; +import type { Context, Event, EventHint, Integration } from '@sentry/types'; import { getExpoGoVersion, @@ -26,71 +26,64 @@ export interface ReactNativeContext extends Context { } /** Loads React Native context at runtime */ -export class ReactNativeInfo implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'ReactNativeInfo'; +export const reactNativeInfoIntegration = (): Integration => { + return { + name: 'ReactNativeInfo', + setupOnce: () => { + // noop + }, + processEvent, + }; +}; - /** - * @inheritDoc - */ - public name: string = ReactNativeInfo.id; +function processEvent(event: Event, hint: EventHint): Event { + const reactNativeError = hint?.originalException ? (hint?.originalException as ReactNativeError) : undefined; - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { - addGlobalEventProcessor(async (event: Event, hint?: EventHint) => { - const reactNativeError = hint?.originalException ? (hint?.originalException as ReactNativeError) : undefined; + const reactNativeContext: ReactNativeContext = { + turbo_module: isTurboModuleEnabled(), + fabric: isFabricEnabled(), + react_native_version: getReactNativeVersion(), + expo: isExpo(), + }; - const reactNativeContext: ReactNativeContext = { - turbo_module: isTurboModuleEnabled(), - fabric: isFabricEnabled(), - react_native_version: getReactNativeVersion(), - expo: isExpo(), - }; - - if (isHermesEnabled()) { - reactNativeContext.js_engine = 'hermes'; - const hermesVersion = getHermesVersion(); - if (hermesVersion) { - reactNativeContext.hermes_version = hermesVersion; - } - reactNativeContext.hermes_debug_info = !isEventWithHermesBytecodeFrames(event); - } else if (reactNativeError?.jsEngine) { - reactNativeContext.js_engine = reactNativeError.jsEngine; - } + if (isHermesEnabled()) { + reactNativeContext.js_engine = 'hermes'; + const hermesVersion = getHermesVersion(); + if (hermesVersion) { + reactNativeContext.hermes_version = hermesVersion; + } + reactNativeContext.hermes_debug_info = !isEventWithHermesBytecodeFrames(event); + } else if (reactNativeError?.jsEngine) { + reactNativeContext.js_engine = reactNativeError.jsEngine; + } - if (reactNativeContext.js_engine === 'hermes') { - event.tags = { - hermes: 'true', - ...event.tags, - }; - } + if (reactNativeContext.js_engine === 'hermes') { + event.tags = { + hermes: 'true', + ...event.tags, + }; + } - if (reactNativeError?.componentStack) { - reactNativeContext.component_stack = reactNativeError.componentStack; - } + if (reactNativeError?.componentStack) { + reactNativeContext.component_stack = reactNativeError.componentStack; + } - const expoGoVersion = getExpoGoVersion(); - if (expoGoVersion) { - reactNativeContext.expo_go_version = expoGoVersion; - } + const expoGoVersion = getExpoGoVersion(); + if (expoGoVersion) { + reactNativeContext.expo_go_version = expoGoVersion; + } - const expoSdkVersion = getExpoSdkVersion(); - if (expoSdkVersion) { - reactNativeContext.expo_sdk_version = expoSdkVersion; - } + const expoSdkVersion = getExpoSdkVersion(); + if (expoSdkVersion) { + reactNativeContext.expo_sdk_version = expoSdkVersion; + } - event.contexts = { - react_native_context: reactNativeContext, - ...event.contexts, - }; + event.contexts = { + react_native_context: reactNativeContext, + ...event.contexts, + }; - return event; - }); - } + return event; } /** diff --git a/src/js/integrations/release.ts b/src/js/integrations/release.ts index 0ca52c12e2..88b57e1b82 100644 --- a/src/js/integrations/release.ts +++ b/src/js/integrations/release.ts @@ -1,66 +1,58 @@ -import { addGlobalEventProcessor, getCurrentHub } from '@sentry/core'; -import type { Event, Integration } from '@sentry/types'; +import type { BaseTransportOptions, Client, ClientOptions, Event, EventHint, Integration } from '@sentry/types'; import { NATIVE } from '../wrapper'; /** Release integration responsible to load release from file. */ -export class Release implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'Release'; - /** - * @inheritDoc - */ - public name: string = Release.id; - - /** - * @inheritDoc - */ - public setupOnce(): void { - addGlobalEventProcessor(async (event: Event) => { - const self = getCurrentHub().getIntegration(Release); - if (!self) { - return event; - } - - const options = getCurrentHub().getClient()?.getOptions(); +export const nativeReleaseIntegration = (): Integration => { + return { + name: 'Release', + setupOnce: () => { + // noop + }, + processEvent, + }; +}; + +async function processEvent( + event: Event, + _: EventHint, + client: Client>, +): Promise { + const options = client.getOptions(); + + /* + __sentry_release and __sentry_dist is set by the user with setRelease and setDist. If this is used then this is the strongest. + Otherwise we check for the release and dist in the options passed on init, as this is stronger than the release/dist from the native build. + */ + if (typeof event.extra?.__sentry_release === 'string') { + event.release = `${event.extra.__sentry_release}`; + } else if (typeof options?.release === 'string') { + event.release = options.release; + } - /* - __sentry_release and __sentry_dist is set by the user with setRelease and setDist. If this is used then this is the strongest. - Otherwise we check for the release and dist in the options passed on init, as this is stronger than the release/dist from the native build. - */ - if (typeof event.extra?.__sentry_release === 'string') { - event.release = `${event.extra.__sentry_release}`; - } else if (typeof options?.release === 'string') { - event.release = options.release; - } + if (typeof event.extra?.__sentry_dist === 'string') { + event.dist = `${event.extra.__sentry_dist}`; + } else if (typeof options?.dist === 'string') { + event.dist = options.dist; + } - if (typeof event.extra?.__sentry_dist === 'string') { - event.dist = `${event.extra.__sentry_dist}`; - } else if (typeof options?.dist === 'string') { - event.dist = options.dist; - } + if (event.release && event.dist) { + return event; + } - if (event.release && event.dist) { - return event; + try { + const nativeRelease = await NATIVE.fetchNativeRelease(); + if (nativeRelease) { + if (!event.release) { + event.release = `${nativeRelease.id}@${nativeRelease.version}+${nativeRelease.build}`; } - - try { - const nativeRelease = await NATIVE.fetchNativeRelease(); - if (nativeRelease) { - if (!event.release) { - event.release = `${nativeRelease.id}@${nativeRelease.version}+${nativeRelease.build}`; - } - if (!event.dist) { - event.dist = `${nativeRelease.build}`; - } - } - } catch (_Oo) { - // Something went wrong, we just continue + if (!event.dist) { + event.dist = `${nativeRelease.build}`; } - - return event; - }); + } + } catch (_Oo) { + // Something went wrong, we just continue } + + return event; } diff --git a/src/js/integrations/rewriteframes.ts b/src/js/integrations/rewriteframes.ts index 844d55b221..04170d088a 100644 --- a/src/js/integrations/rewriteframes.ts +++ b/src/js/integrations/rewriteframes.ts @@ -1,4 +1,4 @@ -import { RewriteFrames } from '@sentry/integrations'; +import { rewriteFramesIntegration } from '@sentry/integrations'; import type { Integration, StackFrame } from '@sentry/types'; import { Platform } from 'react-native'; @@ -14,7 +14,7 @@ export const IOS_DEFAULT_BUNDLE_NAME = 'app:///main.jsbundle'; * and Expo bundle postfix. */ export function createReactNativeRewriteFrames(): Integration { - return new RewriteFrames({ + return rewriteFramesIntegration({ iteratee: (frame: StackFrame) => { if (frame.platform === 'java' || frame.platform === 'cocoa') { // Because platform is not required in StackFrame type diff --git a/src/js/integrations/screenshot.ts b/src/js/integrations/screenshot.ts index 699916e014..536fabb0a3 100644 --- a/src/js/integrations/screenshot.ts +++ b/src/js/integrations/screenshot.ts @@ -1,58 +1,29 @@ -import type { Event, EventHint, EventProcessor, Integration } from '@sentry/types'; -import { resolvedSyncPromise } from '@sentry/utils'; +import type { Event, EventHint, Integration } from '@sentry/types'; import type { Screenshot as ScreenshotAttachment } from '../wrapper'; import { NATIVE } from '../wrapper'; /** Adds screenshots to error events */ -export class Screenshot implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'Screenshot'; - - /** - * @inheritDoc - */ - public name: string = Screenshot.id; - - /** - * If enabled attaches a screenshot to the event hint. - * - * @deprecated Screenshots are now added in global event processor. - */ - public static attachScreenshotToEventHint( - hint: EventHint, - { attachScreenshot }: { attachScreenshot?: boolean }, - ): PromiseLike { - if (!attachScreenshot) { - return resolvedSyncPromise(hint); - } - - return NATIVE.captureScreenshot().then(screenshots => { - if (screenshots !== null && screenshots.length > 0) { - hint.attachments = [...screenshots, ...(hint?.attachments || [])]; - } - return hint; - }); +export const screenshotIntegration = (): Integration => { + return { + name: 'Screenshot', + setupOnce: () => { + // noop + }, + processEvent, + }; +}; + +async function processEvent(event: Event, hint: EventHint): Promise { + const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + if (!hasException) { + return event; } - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { - addGlobalEventProcessor(async (event: Event, hint: EventHint) => { - const hasException = event.exception && event.exception.values && event.exception.values.length > 0; - if (!hasException) { - return event; - } - - const screenshots: ScreenshotAttachment[] | null = await NATIVE.captureScreenshot(); - if (screenshots && screenshots.length > 0) { - hint.attachments = [...screenshots, ...(hint?.attachments || [])]; - } - - return event; - }); + const screenshots: ScreenshotAttachment[] | null = await NATIVE.captureScreenshot(); + if (screenshots && screenshots.length > 0) { + hint.attachments = [...screenshots, ...(hint?.attachments || [])]; } + + return event; } diff --git a/src/js/integrations/sdkinfo.ts b/src/js/integrations/sdkinfo.ts index 85c8628291..65f5656856 100644 --- a/src/js/integrations/sdkinfo.ts +++ b/src/js/integrations/sdkinfo.ts @@ -1,4 +1,4 @@ -import type { EventProcessor, Integration, Package, SdkInfo as SdkInfoType } from '@sentry/types'; +import type { Event, Integration, Package, SdkInfo as SdkInfoType } from '@sentry/types'; import { logger } from '@sentry/utils'; import { isExpoGo, notWeb } from '../utils/environment'; @@ -19,50 +19,55 @@ export const defaultSdkInfo: DefaultSdkInfo = { }; /** Default SdkInfo instrumentation */ -export class SdkInfo implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'SdkInfo'; +export const sdkInfoIntegration = (): Integration => { + const fetchNativeSdkInfo = createCachedFetchNativeSdkInfo(); - /** - * @inheritDoc - */ - public name: string = SdkInfo.id; + return { + name: 'SdkInfo', + setupOnce: () => { + // noop + }, + processEvent: (event: Event) => processEvent(event, fetchNativeSdkInfo), + }; +}; - private _nativeSdkPackage: Package | null = null; +async function processEvent(event: Event, fetchNativeSdkInfo: () => Promise): Promise { + const nativeSdkPackage = await fetchNativeSdkInfo(); - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { - addGlobalEventProcessor(async event => { - // this._nativeSdkInfo should be defined a following time so this call won't always be awaited. - if (this._nativeSdkPackage === null) { - try { - this._nativeSdkPackage = await NATIVE.fetchNativeSdkInfo(); - } catch (e) { - // If this fails, go ahead as usual as we would rather have the event be sent with a package missing. - if (notWeb() && !isExpoGo()) { - logger.warn( - '[SdkInfo] Native SDK Info retrieval failed...something could be wrong with your Sentry installation:', - ); - logger.warn(e); - } - } - } + event.platform = event.platform || 'javascript'; + event.sdk = event.sdk || {}; + event.sdk.name = event.sdk.name || defaultSdkInfo.name; + event.sdk.version = event.sdk.version || defaultSdkInfo.version; + event.sdk.packages = [ + // default packages are added by baseclient and should not be added here + ...(event.sdk.packages || []), + ...((nativeSdkPackage && [nativeSdkPackage]) || []), + ]; - event.platform = event.platform || 'javascript'; - event.sdk = event.sdk || {}; - event.sdk.name = event.sdk.name || defaultSdkInfo.name; - event.sdk.version = event.sdk.version || defaultSdkInfo.version; - event.sdk.packages = [ - // default packages are added by baseclient and should not be added here - ...(event.sdk.packages || []), - ...((this._nativeSdkPackage && [this._nativeSdkPackage]) || []), - ]; + return event; +} - return event; - }); +function createCachedFetchNativeSdkInfo(): () => Promise { + if (!notWeb() || isExpoGo()) { + return () => { + return Promise.resolve(null); + }; } + + return async () => { + let isCached: boolean = false; + let nativeSdkPackageCache: Package | null = null; + if (isCached) { + return nativeSdkPackageCache; + } + + try { + nativeSdkPackageCache = await NATIVE.fetchNativeSdkInfo(); + isCached = true; + } catch (e) { + logger.warn('Could not fetch native sdk info.', e); + } + + return nativeSdkPackageCache; + }; } diff --git a/src/js/integrations/spotlight.ts b/src/js/integrations/spotlight.ts index 156c975490..ea1f51214a 100644 --- a/src/js/integrations/spotlight.ts +++ b/src/js/integrations/spotlight.ts @@ -1,4 +1,4 @@ -import type { Client, Envelope, EventProcessor, Integration } from '@sentry/types'; +import type { BaseTransportOptions, Client, ClientOptions, Envelope, Integration } from '@sentry/types'; import { logger, serializeEnvelope } from '@sentry/utils'; import { makeUtf8TextEncoder } from '../transports/TextEncoder'; @@ -28,13 +28,12 @@ export function Spotlight({ return { name: 'Spotlight', - setupOnce(_: (callback: EventProcessor) => void, getCurrentHub) { - const client = getCurrentHub().getClient(); - if (client) { - setup(client, sidecarUrl); - } else { - logger.warn('[Spotlight] Could not initialize Sidecar integration due to missing Client'); - } + setupOnce(): void { + // nothing to do here + }, + + setup(client: Client>): void { + setup(client, sidecarUrl); }, }; } diff --git a/src/js/integrations/viewhierarchy.ts b/src/js/integrations/viewhierarchy.ts index 15b29b0d25..f148b83ffb 100644 --- a/src/js/integrations/viewhierarchy.ts +++ b/src/js/integrations/viewhierarchy.ts @@ -1,54 +1,47 @@ -import type { Event, EventHint, EventProcessor, Integration } from '@sentry/types'; +import type { Event, EventHint, Integration } from '@sentry/types'; import { logger } from '@sentry/utils'; import { NATIVE } from '../wrapper'; -/** Adds ViewHierarchy to error events */ -export class ViewHierarchy implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'ViewHierarchy'; - - private static _fileName: string = 'view-hierarchy.json'; - private static _contentType: string = 'application/json'; - private static _attachmentType: string = 'event.view_hierarchy'; - - /** - * @inheritDoc - */ - public name: string = ViewHierarchy.id; - - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { - addGlobalEventProcessor(async (event: Event, hint: EventHint) => { - const hasException = event.exception && event.exception.values && event.exception.values.length > 0; - if (!hasException) { - return event; - } +const filename: string = 'view-hierarchy.json'; +const contentType: string = 'application/json'; +const attachmentType: string = 'event.view_hierarchy'; - let viewHierarchy: Uint8Array | null = null; - try { - viewHierarchy = await NATIVE.fetchViewHierarchy(); - } catch (e) { - logger.error('Failed to get view hierarchy from native.', e); - } +/** Adds ViewHierarchy to error events */ +export const viewHierarchyIntegration = (): Integration => { + return { + name: 'ViewHierarchy', + setupOnce: () => { + // noop + }, + processEvent, + }; +}; + +async function processEvent(event: Event, hint: EventHint): Promise { + const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + if (!hasException) { + return event; + } - if (viewHierarchy) { - hint.attachments = [ - { - filename: ViewHierarchy._fileName, - contentType: ViewHierarchy._contentType, - attachmentType: ViewHierarchy._attachmentType, - data: viewHierarchy, - }, - ...(hint?.attachments || []), - ]; - } + let viewHierarchy: Uint8Array | null = null; + try { + viewHierarchy = await NATIVE.fetchViewHierarchy(); + } catch (e) { + logger.error('Failed to get view hierarchy from native.', e); + } - return event; - }); + if (viewHierarchy) { + hint.attachments = [ + { + filename, + contentType, + attachmentType, + data: viewHierarchy, + }, + ...(hint?.attachments || []), + ]; } + + return event; } diff --git a/test/integrations/debugsymbolicator.test.ts b/test/integrations/debugsymbolicator.test.ts index ab1465d8a0..035fa63fe2 100644 --- a/test/integrations/debugsymbolicator.test.ts +++ b/test/integrations/debugsymbolicator.test.ts @@ -1,37 +1,32 @@ -import type { Event, EventHint, Hub, Integration, StackFrame } from '@sentry/types'; +jest.mock('../../src/js/integrations/debugsymbolicatorutils'); -import { DebugSymbolicator } from '../../src/js/integrations/debugsymbolicator'; +import type { Client, Event, EventHint, StackFrame } from '@sentry/types'; + +import { debugSymbolicatorIntegration } from '../../src/js/integrations/debugsymbolicator'; +import { + fetchSourceContext, + getDevServer, + parseErrorStack, + symbolicateStackTrace, +} from '../../src/js/integrations/debugsymbolicatorutils'; import type * as ReactNative from '../../src/js/vendor/react-native'; -interface MockDebugSymbolicator extends Integration { - _parseErrorStack: jest.Mock, [string]>; - _symbolicateStackTrace: jest.Mock< - Promise, - [Array, Record | undefined] - >; - _getDevServer: jest.Mock; - _fetchSourceContext: jest.Mock, [string, Array, number]>; +async function processEvent(mockedEvent: Event, mockedHint: EventHint): Promise { + return debugSymbolicatorIntegration().processEvent!(mockedEvent, mockedHint, {} as Client); } describe('Debug Symbolicator Integration', () => { - let integration: MockDebugSymbolicator; - const mockGetCurrentHub = () => - ({ - getIntegration: () => integration, - } as unknown as Hub); - beforeEach(() => { - integration = new DebugSymbolicator() as unknown as MockDebugSymbolicator; - integration._parseErrorStack = jest.fn().mockReturnValue([]); - integration._symbolicateStackTrace = jest.fn().mockReturnValue( + (parseErrorStack as jest.Mock).mockReturnValue([]); + (symbolicateStackTrace as jest.Mock).mockReturnValue( Promise.resolve({ stack: [], }), ); - integration._getDevServer = jest.fn().mockReturnValue({ + (getDevServer as jest.Mock).mockReturnValue({ url: 'http://localhost:8081', }); - integration._fetchSourceContext = jest.fn().mockReturnValue(Promise.resolve(null)); + (fetchSourceContext as jest.Mock).mockReturnValue(Promise.resolve(null)); }); describe('parse stack', () => { @@ -60,7 +55,7 @@ describe('Debug Symbolicator Integration', () => { ]; beforeEach(() => { - integration._parseErrorStack = jest.fn().mockReturnValue(>[ + (parseErrorStack as jest.Mock).mockReturnValue(>[ { file: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', lineNumber: 1, @@ -75,7 +70,7 @@ describe('Debug Symbolicator Integration', () => { }, ]); - integration._symbolicateStackTrace = jest.fn().mockReturnValue( + (symbolicateStackTrace as jest.Mock).mockReturnValue( Promise.resolve({ stack: [ { @@ -96,7 +91,7 @@ describe('Debug Symbolicator Integration', () => { }); it('should symbolicate errors stack trace', async () => { - const symbolicatedEvent = await executeIntegrationFor( + const symbolicatedEvent = await processEvent( { exception: { values: [ @@ -148,7 +143,7 @@ describe('Debug Symbolicator Integration', () => { }); it('should symbolicate synthetic error stack trace for exception', async () => { - const symbolicatedEvent = await executeIntegrationFor( + const symbolicatedEvent = await processEvent( { exception: { values: [ @@ -201,7 +196,7 @@ describe('Debug Symbolicator Integration', () => { }); it('should symbolicate synthetic error stack trace for message', async () => { - const symbolicatedEvent = await executeIntegrationFor( + const symbolicatedEvent = await processEvent( { threads: { values: [ @@ -249,7 +244,7 @@ describe('Debug Symbolicator Integration', () => { }); it('skips first frame (callee) for exception', async () => { - const symbolicatedEvent = await executeIntegrationFor( + const symbolicatedEvent = await processEvent( { exception: { values: [ @@ -297,7 +292,7 @@ describe('Debug Symbolicator Integration', () => { }); it('skips first frame (callee) for message', async () => { - const symbolicatedEvent = await executeIntegrationFor( + const symbolicatedEvent = await processEvent( { threads: { values: [ @@ -340,21 +335,4 @@ describe('Debug Symbolicator Integration', () => { }); }); }); - - function executeIntegrationFor(mockedEvent: Event, hint: EventHint): Promise { - return new Promise((resolve, reject) => { - if (!integration) { - throw new Error('Setup integration before executing the test.'); - } - - integration.setupOnce(async eventProcessor => { - try { - const processedEvent = await eventProcessor(mockedEvent, hint); - resolve(processedEvent); - } catch (e) { - reject(e); - } - }, mockGetCurrentHub); - }); - } }); diff --git a/test/integrations/devicecontext.test.ts b/test/integrations/devicecontext.test.ts index a2644ca58a..3bc120c7fa 100644 --- a/test/integrations/devicecontext.test.ts +++ b/test/integrations/devicecontext.test.ts @@ -1,7 +1,6 @@ -import type { Hub } from '@sentry/core'; -import type { Event, SeverityLevel } from '@sentry/types'; +import type { Client, Event, EventHint, SeverityLevel } from '@sentry/types'; -import { DeviceContext } from '../../src/js/integrations'; +import { deviceContextIntegration } from '../../src/js/integrations'; import type { NativeDeviceContextsResponse } from '../../src/js/NativeRNSentry'; import { NATIVE } from '../../src/js/wrapper'; @@ -15,20 +14,9 @@ jest.mock('react-native', () => ({ })); describe('Device Context Integration', () => { - let integration: DeviceContext; - - const mockGetCurrentHub = () => - ({ - getIntegration: () => integration, - } as unknown as Hub); - - beforeEach(() => { - integration = new DeviceContext(); - }); - it('add native user', async () => { ( - await executeIntegrationWith({ + await processEventWith({ nativeContexts: { user: { id: 'native-user' } }, }) ).expectEvent.toStrictEqualToNativeContexts(); @@ -36,7 +24,7 @@ describe('Device Context Integration', () => { it('do not overwrite event user', async () => { ( - await executeIntegrationWith({ + await processEventWith({ nativeContexts: { user: { id: 'native-user' } }, mockEvent: { user: { id: 'event-user' } }, }) @@ -45,7 +33,7 @@ describe('Device Context Integration', () => { it('do not overwrite event app context', async () => { ( - await executeIntegrationWith({ + await processEventWith({ nativeContexts: { app: { view_names: ['native view'] } }, mockEvent: { contexts: { app: { view_names: ['Home'] } } }, }) @@ -53,7 +41,7 @@ describe('Device Context Integration', () => { }); it('merge event context app', async () => { - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { contexts: { app: { native: 'value' } } }, mockEvent: { contexts: { app: { event_app: 'value' } } }, }); @@ -68,7 +56,7 @@ describe('Device Context Integration', () => { }); it('merge event context app even when event app doesnt exist', async () => { - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { contexts: { app: { native: 'value' } } }, mockEvent: { contexts: { keyContext: { key: 'value' } } }, }); @@ -85,7 +73,7 @@ describe('Device Context Integration', () => { }); it('merge event and native contexts', async () => { - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { contexts: { duplicate: { context: 'native-value' }, native: { context: 'value' } } }, mockEvent: { contexts: { duplicate: { context: 'event-value' }, event: { context: 'value' } } }, }); @@ -99,7 +87,7 @@ describe('Device Context Integration', () => { }); it('merge native tags', async () => { - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { tags: { duplicate: 'native-tag', native: 'tag' } }, mockEvent: { tags: { duplicate: 'event-tag', event: 'tag' } }, }); @@ -113,7 +101,7 @@ describe('Device Context Integration', () => { }); it('merge native extra', async () => { - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { extra: { duplicate: 'native-extra', native: 'extra' } }, mockEvent: { extra: { duplicate: 'event-extra', event: 'extra' } }, }); @@ -127,7 +115,7 @@ describe('Device Context Integration', () => { }); it('merge fingerprints', async () => { - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { fingerprint: ['duplicate-fingerprint', 'native-fingerprint'] }, mockEvent: { fingerprint: ['duplicate-fingerprint', 'event-fingerprint'] }, }); @@ -138,7 +126,7 @@ describe('Device Context Integration', () => { it('add native level', async () => { ( - await executeIntegrationWith({ + await processEventWith({ nativeContexts: { level: 'fatal' }, }) ).expectEvent.toStrictEqualToNativeContexts(); @@ -146,7 +134,7 @@ describe('Device Context Integration', () => { it('do not overwrite event level', async () => { ( - await executeIntegrationWith({ + await processEventWith({ nativeContexts: { level: 'native-level' }, mockEvent: { level: 'info' }, }) @@ -155,7 +143,7 @@ describe('Device Context Integration', () => { it('add native environment', async () => { ( - await executeIntegrationWith({ + await processEventWith({ nativeContexts: { environment: 'native-environment' }, }) ).expectEvent.toStrictEqualToNativeContexts(); @@ -163,7 +151,7 @@ describe('Device Context Integration', () => { it('do not overwrite event environment', async () => { ( - await executeIntegrationWith({ + await processEventWith({ nativeContexts: { environment: 'native-environment' }, mockEvent: { environment: 'event-environment' }, }) @@ -171,7 +159,7 @@ describe('Device Context Integration', () => { }); it('use only native breadcrumbs', async () => { - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { breadcrumbs: [{ message: 'duplicate-breadcrumb' }, { message: 'native-breadcrumb' }] }, mockEvent: { breadcrumbs: [{ message: 'duplicate-breadcrumb' }, { message: 'event-breadcrumb' }] }, }); @@ -182,7 +170,7 @@ describe('Device Context Integration', () => { it('adds in_foreground false to native app contexts', async () => { mockCurrentAppState = 'background'; - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { contexts: { app: { native: 'value' } } }, }); expect(processedEvent).toStrictEqual({ @@ -197,7 +185,7 @@ describe('Device Context Integration', () => { it('adds in_foreground to native app contexts', async () => { mockCurrentAppState = 'active'; - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { contexts: { app: { native: 'value' } } }, }); expect(processedEvent).toStrictEqual({ @@ -212,7 +200,7 @@ describe('Device Context Integration', () => { it('do not add in_foreground if unknown', async () => { mockCurrentAppState = 'unknown'; - const { processedEvent } = await executeIntegrationWith({ + const { processedEvent } = await processEventWith({ nativeContexts: { contexts: { app: { native: 'value' } } }, }); expect(processedEvent).toStrictEqual({ @@ -223,45 +211,36 @@ describe('Device Context Integration', () => { }, }); }); +}); - async function executeIntegrationWith({ - nativeContexts, - mockEvent, - }: { - nativeContexts: Record; - mockEvent?: Event; - }): Promise<{ - processedEvent: Event | null; +async function processEventWith({ + nativeContexts, + mockEvent, +}: { + nativeContexts: Record; + mockEvent?: Event; +}): Promise<{ + processedEvent: Event | null; + expectEvent: { + toStrictEqualToNativeContexts: () => void; + toStrictEqualMockEvent: () => void; + }; +}> { + (NATIVE.fetchNativeDeviceContexts as jest.MockedFunction).mockImplementation( + () => Promise.resolve(nativeContexts as NativeDeviceContextsResponse), + ); + const originalNativeContexts = { ...nativeContexts }; + const originalMockEvent = { ...mockEvent }; + const processedEvent = await processEvent(mockEvent ?? {}); + return { + processedEvent, expectEvent: { - toStrictEqualToNativeContexts: () => void; - toStrictEqualMockEvent: () => void; - }; - }> { - ( - NATIVE.fetchNativeDeviceContexts as jest.MockedFunction - ).mockImplementation(() => Promise.resolve(nativeContexts as NativeDeviceContextsResponse)); - const originalNativeContexts = { ...nativeContexts }; - const originalMockEvent = { ...mockEvent }; - const processedEvent = await executeIntegrationFor(mockEvent ?? {}); - return { - processedEvent, - expectEvent: { - toStrictEqualToNativeContexts: () => expect(processedEvent).toStrictEqual(originalNativeContexts), - toStrictEqualMockEvent: () => expect(processedEvent).toStrictEqual(originalMockEvent), - }, - }; - } - - function executeIntegrationFor(mockedEvent: Event): Promise { - return new Promise((resolve, reject) => { - integration.setupOnce(async eventProcessor => { - try { - const processedEvent = await eventProcessor(mockedEvent, {}); - resolve(processedEvent); - } catch (e) { - reject(e); - } - }, mockGetCurrentHub); - }); - } -}); + toStrictEqualToNativeContexts: () => expect(processedEvent).toStrictEqual(originalNativeContexts), + toStrictEqualMockEvent: () => expect(processedEvent).toStrictEqual(originalMockEvent), + }, + }; +} + +function processEvent(mockedEvent: Event): Event | null | PromiseLike { + return deviceContextIntegration().processEvent!(mockedEvent, {} as EventHint, {} as Client); +} diff --git a/test/integrations/eventorigin.test.ts b/test/integrations/eventorigin.test.ts index faae0f56e6..b359929fdb 100644 --- a/test/integrations/eventorigin.test.ts +++ b/test/integrations/eventorigin.test.ts @@ -1,30 +1,13 @@ -import type { Event } from '@sentry/types'; +import type { Client } from '@sentry/types'; -import { EventOrigin } from '../../src/js/integrations'; +import { eventOriginIntegration } from '../../src/js/integrations'; describe('Event Origin', () => { - it('Adds event.origin and event.environment javascript tags to events', done => { - const integration = new EventOrigin(); + it('Adds event.origin and event.environment javascript tags to events', async () => { + const integration = eventOriginIntegration(); - const mockEvent: Event = {}; - - integration.setupOnce(async eventProcessor => { - try { - const processedEvent = await eventProcessor(mockEvent, {}); - - expect(processedEvent).toBeDefined(); - if (processedEvent) { - expect(processedEvent.tags).toBeDefined(); - if (processedEvent.tags) { - expect(processedEvent.tags['event.origin']).toBe('javascript'); - expect(processedEvent.tags['event.environment']).toBe('javascript'); - } - } - - done(); - } catch (e) { - done(e); - } - }); + const processedEvent = await integration.processEvent!({}, {}, {} as Client); + expect(processedEvent?.tags?.['event.origin']).toBe('javascript'); + expect(processedEvent?.tags?.['event.environment']).toBe('javascript'); }); }); diff --git a/test/integrations/expocontext.test.ts b/test/integrations/expocontext.test.ts index 95f24095d5..7b449269f9 100644 --- a/test/integrations/expocontext.test.ts +++ b/test/integrations/expocontext.test.ts @@ -1,23 +1,11 @@ -import type { Hub } from '@sentry/core'; -import type { Event } from '@sentry/types'; +import type { Client, Event } from '@sentry/types'; -import { ExpoContext } from '../../src/js/integrations/expocontext'; +import { expoContextIntegration } from '../../src/js/integrations/expocontext'; import { getExpoDevice } from '../../src/js/utils/expomodules'; jest.mock('../../src/js/utils/expomodules'); describe('Expo Context Integration', () => { - let integration: ExpoContext; - - const mockGetCurrentHub = () => - ({ - getIntegration: () => integration, - } as unknown as Hub); - - beforeEach(() => { - integration = new ExpoContext(); - }); - it('does not add device context because expo device module is not available', async () => { (getExpoDevice as jest.Mock).mockReturnValue(undefined); const actualEvent = await executeIntegrationFor({}); @@ -112,16 +100,7 @@ describe('Expo Context Integration', () => { }); }); - function executeIntegrationFor(mockedEvent: Event): Promise { - return new Promise((resolve, reject) => { - integration.setupOnce(async eventProcessor => { - try { - const processedEvent = await eventProcessor(mockedEvent, {}); - resolve(processedEvent); - } catch (e) { - reject(e); - } - }, mockGetCurrentHub); - }); + function executeIntegrationFor(mockedEvent: Event): Event { + return expoContextIntegration().processEvent!(mockedEvent, {}, {} as Client) as Event; } }); diff --git a/test/integrations/integrationsexecutionorder.test.ts b/test/integrations/integrationsexecutionorder.test.ts index 7a9e9f5def..bd003eae7a 100644 --- a/test/integrations/integrationsexecutionorder.test.ts +++ b/test/integrations/integrationsexecutionorder.test.ts @@ -31,7 +31,7 @@ describe('Integration execution order', () => { client.setupIntegrations(); client.captureException(new Error('test')); - jest.runAllTimers(); + await client.flush(); expect(nativeLinkedErrors.preprocessEvent).toHaveBeenCalledBefore(rewriteFrames.processEvent!); }); @@ -56,7 +56,7 @@ describe('Integration execution order', () => { client.setupIntegrations(); client.captureException(new Error('test')); - jest.runAllTimers(); + await client.flush(); expect(linkedErrors.preprocessEvent).toHaveBeenCalledBefore(rewriteFrames.processEvent!); }); diff --git a/test/integrations/modulesloader.test.ts b/test/integrations/modulesloader.test.ts index f4315b8ad1..61edd558a8 100644 --- a/test/integrations/modulesloader.test.ts +++ b/test/integrations/modulesloader.test.ts @@ -1,17 +1,11 @@ -import type { Event, EventHint } from '@sentry/types'; +import type { Client, Event, EventHint } from '@sentry/types'; -import { ModulesLoader } from '../../src/js/integrations'; +import { modulesLoaderIntegration } from '../../src/js/integrations'; import { NATIVE } from '../../src/js/wrapper'; jest.mock('../../src/js/wrapper'); describe('Modules Loader', () => { - let integration: ModulesLoader; - - beforeEach(() => { - integration = new ModulesLoader(); - }); - it('integration event processor does not throw on native error', async () => { (NATIVE.fetchModules as jest.Mock).mockImplementation(() => { throw new Error('Test Error'); @@ -46,16 +40,11 @@ describe('Modules Loader', () => { }); }); - function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint = {}): Promise { - return new Promise((resolve, reject) => { - integration.setupOnce(async eventProcessor => { - try { - const processedEvent = await eventProcessor(mockedEvent, mockedHint); - resolve(processedEvent); - } catch (e) { - reject(e); - } - }); - }); + function executeIntegrationFor( + mockedEvent: Event, + mockedHint: EventHint = {}, + ): Event | null | PromiseLike { + const integration = modulesLoaderIntegration(); + return integration.processEvent!(mockedEvent, mockedHint, {} as Client); } }); diff --git a/test/integrations/nativelinkederrors.test.ts b/test/integrations/nativelinkederrors.test.ts index 9303255d43..3f1781fd40 100644 --- a/test/integrations/nativelinkederrors.test.ts +++ b/test/integrations/nativelinkederrors.test.ts @@ -1,7 +1,7 @@ import { defaultStackParser } from '@sentry/browser'; import type { Client, DebugImage, Event, EventHint, ExtendedError } from '@sentry/types'; -import { NativeLinkedErrors } from '../../src/js/integrations/nativelinkederrors'; +import { nativeLinkedErrorsIntegration } from '../../src/js/integrations/nativelinkederrors'; import type { NativeStackFrames } from '../../src/js/NativeRNSentry'; import { NATIVE } from '../../src/js/wrapper'; @@ -267,7 +267,7 @@ describe('NativeLinkedErrors', () => { }, ); - expect(NATIVE.fetchNativePackageName).toBeCalledTimes(1); + expect(NATIVE.fetchNativePackageName).toBeCalledTimes(0); // not need for iOS expect(NATIVE.fetchNativeStackFramesBy).toBeCalledTimes(1); expect(NATIVE.fetchNativeStackFramesBy).toBeCalledWith([6446871344, 6442783348, 4350761216]); expect(actualEvent).toEqual( @@ -346,8 +346,8 @@ function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint): Event }), } as unknown as Client; - const integration = new NativeLinkedErrors(); - integration.preprocessEvent(mockedEvent, mockedHint, mockedClient); + const integration = nativeLinkedErrorsIntegration(); + integration.preprocessEvent!(mockedEvent, mockedHint, mockedClient); return mockedEvent; } diff --git a/test/integrations/reactnativeerrorhandlers.test.ts b/test/integrations/reactnativeerrorhandlers.test.ts index 3a4285f3e0..66249b3966 100644 --- a/test/integrations/reactnativeerrorhandlers.test.ts +++ b/test/integrations/reactnativeerrorhandlers.test.ts @@ -1,78 +1,29 @@ -import { BrowserClient, defaultIntegrations, defaultStackParser } from '@sentry/browser'; +jest.mock('../../src/js/integrations/reactnativeerrorhandlersutils'); -const mockBrowserClient: BrowserClient = new BrowserClient({ - stackParser: defaultStackParser, - integrations: defaultIntegrations, - transport: jest.fn(), -}); - -let mockHubCaptureException: jest.Mock; - -jest.mock('@sentry/core', () => { - const core = jest.requireActual('@sentry/core'); - - const scope = { - getAttachments: jest.fn(), - }; - - const client = { - getOptions: () => ({}), - eventFromException: (_exception: any, _hint?: EventHint): PromiseLike => - mockBrowserClient.eventFromException(_exception, _hint), - }; - - const hub = { - getClient: () => client, - getScope: () => scope, - captureEvent: jest.fn(), - captureException: jest.fn(), - }; - - mockHubCaptureException = hub.captureException; - - return { - ...core, - addGlobalEventProcessor: jest.fn(), - getCurrentHub: () => hub, - }; -}); - -jest.mock('@sentry/utils', () => { - const utils = jest.requireActual('@sentry/utils'); - return { - ...utils, - logger: { - log: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, - }; -}); - -import { getCurrentHub } from '@sentry/core'; -import type { Event, EventHint, ExtendedError, Integration, SeverityLevel } from '@sentry/types'; +import { type Hub, setCurrentClient } from '@sentry/core'; +import type { ExtendedError, SeverityLevel } from '@sentry/types'; -import { ReactNativeErrorHandlers } from '../../src/js/integrations/reactnativeerrorhandlers'; - -interface MockTrackingOptions { - allRejections: boolean; - onUnhandled: jest.Mock; - onHandled: jest.Mock; -} - -interface MockedReactNativeErrorHandlers extends Integration { - _loadRejectionTracking: jest.Mock< - { - disable: jest.Mock; - enable: jest.Mock; - }, - [] - >; -} +import { reactNativeErrorHandlersIntegration } from '../../src/js/integrations/reactnativeerrorhandlers'; +import { requireRejectionTracking } from '../../src/js/integrations/reactnativeerrorhandlersutils'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; describe('ReactNativeErrorHandlers', () => { + let client: TestClient; + let mockDisable: jest.Mock; + let mockEnable: jest.Mock; + beforeEach(() => { + mockDisable = jest.fn(); + mockEnable = jest.fn(); + (requireRejectionTracking as jest.Mock).mockReturnValue({ + disable: mockDisable, + enable: mockEnable, + }); ErrorUtils.getGlobalHandler = () => jest.fn(); + + client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); }); afterEach(() => { @@ -89,37 +40,43 @@ describe('ReactNativeErrorHandlers', () => { errorHandlerCallback = _callback as typeof errorHandlerCallback; }); - const integration = new ReactNativeErrorHandlers(); + const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!( + () => {}, + () => ({} as Hub), + ); expect(ErrorUtils.setGlobalHandler).toHaveBeenCalledWith(errorHandlerCallback); }); test('Sets handled:false on a fatal error', async () => { await errorHandlerCallback(new Error('Test Error'), true); + await client.flush(); - const [event] = getActualCaptureEventArgs(); + const event = client.event; - expect(event.level).toBe('fatal' as SeverityLevel); - expect(event.exception?.values?.[0].mechanism?.handled).toBe(false); - expect(event.exception?.values?.[0].mechanism?.type).toBe('onerror'); + expect(event?.level).toBe('fatal' as SeverityLevel); + expect(event?.exception?.values?.[0].mechanism?.handled).toBe(false); + expect(event?.exception?.values?.[0].mechanism?.type).toBe('onerror'); }); test('Does not set handled:false on a non-fatal error', async () => { await errorHandlerCallback(new Error('Test Error'), false); + await client.flush(); - const [event] = getActualCaptureEventArgs(); + const event = client.event; - expect(event.level).toBe('error' as SeverityLevel); - expect(event.exception?.values?.[0].mechanism?.handled).toBe(true); - expect(event.exception?.values?.[0].mechanism?.type).toBe('generic'); + expect(event?.level).toBe('error' as SeverityLevel); + expect(event?.exception?.values?.[0].mechanism?.handled).toBe(true); + expect(event?.exception?.values?.[0].mechanism?.type).toBe('generic'); }); test('Includes original exception in hint', async () => { await errorHandlerCallback(new Error('Test Error'), false); + await client.flush(); - const [, hint] = getActualCaptureEventArgs(); + const hint = client.hint; expect(hint).toEqual(expect.objectContaining({ originalException: new Error('Test Error') })); }); @@ -127,19 +84,17 @@ describe('ReactNativeErrorHandlers', () => { describe('onUnhandledRejection', () => { test('unhandled rejected promise is captured with synthetical error', async () => { - mockHubCaptureException.mockClear(); - const integration = new ReactNativeErrorHandlers(); - const mockDisable = jest.fn(); - const mockEnable = jest.fn(); - (integration as unknown as MockedReactNativeErrorHandlers)._loadRejectionTracking = jest.fn(() => ({ - disable: mockDisable, - enable: mockEnable, - })); - integration.setupOnce(); + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!( + () => {}, + () => ({} as Hub), + ); const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; actualTrackingOptions?.onUnhandled?.(1, 'Test Error'); - const actualSyntheticError = mockHubCaptureException.mock.calls[0][1].syntheticException; + + await client.flush(); + const actualSyntheticError = client.hint?.syntheticException; expect(mockDisable).not.toHaveBeenCalled(); expect(mockEnable).toHaveBeenCalledWith( @@ -154,19 +109,17 @@ describe('ReactNativeErrorHandlers', () => { }); test('error like unhandled rejected promise is captured without synthetical error', async () => { - mockHubCaptureException.mockClear(); - const integration = new ReactNativeErrorHandlers(); - const mockDisable = jest.fn(); - const mockEnable = jest.fn(); - (integration as unknown as MockedReactNativeErrorHandlers)._loadRejectionTracking = jest.fn(() => ({ - disable: mockDisable, - enable: mockEnable, - })); - integration.setupOnce(); + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!( + () => {}, + () => ({} as Hub), + ); const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; actualTrackingOptions?.onUnhandled?.(1, new Error('Test Error')); - const actualSyntheticError = mockHubCaptureException.mock.calls[0][1].syntheticException; + + await client.flush(); + const actualSyntheticError = client.hint?.syntheticException; expect(mockDisable).not.toHaveBeenCalled(); expect(mockEnable).toHaveBeenCalledWith( @@ -181,10 +134,3 @@ describe('ReactNativeErrorHandlers', () => { }); }); }); - -function getActualCaptureEventArgs() { - const hub = getCurrentHub(); - const mockCall = (hub.captureEvent as jest.MockedFunction).mock.calls[0]; - - return mockCall; -} diff --git a/test/integrations/reactnativeinfo.test.ts b/test/integrations/reactnativeinfo.test.ts index 0c27f88f32..2b6819e152 100644 --- a/test/integrations/reactnativeinfo.test.ts +++ b/test/integrations/reactnativeinfo.test.ts @@ -1,8 +1,8 @@ -import type { Event, EventHint } from '@sentry/types'; +import type { Client, Event, EventHint } from '@sentry/types'; import type { ReactNativeError } from '../../src/js/integrations/debugsymbolicator'; import type { ReactNativeContext } from '../../src/js/integrations/reactnativeinfo'; -import { ReactNativeInfo } from '../../src/js/integrations/reactnativeinfo'; +import { reactNativeInfoIntegration } from '../../src/js/integrations/reactnativeinfo'; let mockedIsHermesEnabled: jest.Mock; let mockedIsTurboModuleEnabled: jest.Mock; @@ -269,16 +269,7 @@ function expectMocksToBeCalledOnce() { expect(mockedGetExpoSdkVersion).toBeCalledTimes(1); } -function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint): Promise { - const integration = new ReactNativeInfo(); - return new Promise((resolve, reject) => { - integration.setupOnce(async eventProcessor => { - try { - const processedEvent = await eventProcessor(mockedEvent, mockedHint); - resolve(processedEvent); - } catch (e) { - reject(e); - } - }); - }); +function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint): Event | null | PromiseLike { + const integration = reactNativeInfoIntegration(); + return integration.processEvent!(mockedEvent, mockedHint, {} as Client); } diff --git a/test/integrations/release.test.ts b/test/integrations/release.test.ts index fc543a9ad1..8a5a3e4240 100644 --- a/test/integrations/release.test.ts +++ b/test/integrations/release.test.ts @@ -1,30 +1,10 @@ -import { addGlobalEventProcessor, getCurrentHub } from '@sentry/core'; -import type { EventProcessor } from '@sentry/types'; +import type { Client } from '@sentry/types'; -import { Release } from '../../src/js/integrations/release'; - -const mockRelease = Release; - -jest.mock('@sentry/core', () => { - const client = { - getOptions: jest.fn(), - }; - - const hub = { - getClient: () => client, - // out-of-scope variables have to be prefixed with `mock` caseSensitive - getIntegration: () => mockRelease, - }; - - return { - addGlobalEventProcessor: jest.fn(), - getCurrentHub: () => hub, - }; -}); +import { nativeReleaseIntegration } from '../../src/js/integrations/release'; jest.mock('../../src/js/wrapper', () => ({ NATIVE: { - fetchNativeRelease: async () => ({ + fetchNativeRelease: () => ({ build: 'native_build', id: 'native_id', version: 'native_version', @@ -34,116 +14,51 @@ jest.mock('../../src/js/wrapper', () => ({ describe('Tests the Release integration', () => { test('Uses release from native SDK if release/dist are not present in options.', async () => { - const releaseIntegration = new Release(); - - let eventProcessor: EventProcessor = () => null; - - // @ts-expect-error Mock - addGlobalEventProcessor.mockImplementation(e => (eventProcessor = e)); - releaseIntegration.setupOnce(); - - expect(addGlobalEventProcessor).toBeCalled(); + const releaseIntegration = nativeReleaseIntegration(); - const client = getCurrentHub().getClient(); - - // @ts-expect-error Mock - client.getOptions.mockImplementation(() => ({})); - - const event = await eventProcessor({}, {}); + const event = await releaseIntegration.processEvent!({}, {}, { getOptions: () => ({}) } as Client); expect(event?.release).toBe('native_id@native_version+native_build'); expect(event?.dist).toBe('native_build'); }); test('Uses release from native SDK if release is not present in options.', async () => { - const releaseIntegration = new Release(); - - let eventProcessor: EventProcessor = () => null; - - // @ts-expect-error Mock - addGlobalEventProcessor.mockImplementation(e => (eventProcessor = e)); - releaseIntegration.setupOnce(); + const releaseIntegration = nativeReleaseIntegration(); - const client = getCurrentHub().getClient(); - - // @ts-expect-error Mock - client.getOptions.mockImplementation(() => ({ - dist: 'options_dist', - })); - - const event = await eventProcessor({}, {}); + const event = await releaseIntegration.processEvent!({}, {}, { + getOptions: () => ({ dist: 'options_dist' }), + } as Client); expect(event?.release).toBe('native_id@native_version+native_build'); expect(event?.dist).toBe('options_dist'); }); test('Uses dist from native SDK if dist is not present in options.', async () => { - const releaseIntegration = new Release(); - - let eventProcessor: EventProcessor = () => null; + const releaseIntegration = nativeReleaseIntegration(); - // @ts-expect-error Mock - addGlobalEventProcessor.mockImplementation(e => (eventProcessor = e)); - releaseIntegration.setupOnce(); - - const client = getCurrentHub().getClient(); - - // @ts-expect-error Mock - client.getOptions.mockImplementation(() => ({ - release: 'options_release', - })); - - const event = await eventProcessor({}, {}); + const event = await releaseIntegration.processEvent!({}, {}, { + getOptions: () => ({ release: 'options_release' }), + } as Client); expect(event?.release).toBe('options_release'); expect(event?.dist).toBe('native_build'); }); test('Uses release and dist from options', async () => { - const releaseIntegration = new Release(); - - let eventProcessor: EventProcessor = () => null; - - // @ts-expect-error Mock - addGlobalEventProcessor.mockImplementation(e => (eventProcessor = e)); - releaseIntegration.setupOnce(); - - expect(addGlobalEventProcessor).toBeCalled(); - - const client = getCurrentHub().getClient(); + const releaseIntegration = nativeReleaseIntegration(); - // @ts-expect-error Mock - client.getOptions.mockImplementation(() => ({ - dist: 'options_dist', - release: 'options_release', - })); - - const event = await eventProcessor({}, {}); + const event = await releaseIntegration.processEvent!({}, {}, { + getOptions: () => ({ dist: 'options_dist', release: 'options_release' }), + } as Client); expect(event?.release).toBe('options_release'); expect(event?.dist).toBe('options_dist'); }); test('Uses __sentry_release and __sentry_dist over everything else.', async () => { - const releaseIntegration = new Release(); - - let eventProcessor: EventProcessor = () => null; - - // @ts-expect-error Mock - addGlobalEventProcessor.mockImplementation(e => (eventProcessor = e)); - releaseIntegration.setupOnce(); - - expect(addGlobalEventProcessor).toBeCalled(); + const releaseIntegration = nativeReleaseIntegration(); - const client = getCurrentHub().getClient(); - - // @ts-expect-error Mock - client.getOptions.mockImplementation(() => ({ - dist: 'options_dist', - release: 'options_release', - })); - - const event = await eventProcessor( + const event = await releaseIntegration.processEvent!( { extra: { __sentry_dist: 'sentry_dist', @@ -151,6 +66,9 @@ describe('Tests the Release integration', () => { }, }, {}, + { + getOptions: () => ({ dist: 'options_dist' }), + } as Client, ); expect(event?.release).toBe('sentry_release'); diff --git a/test/integrations/sdkinfo.test.ts b/test/integrations/sdkinfo.test.ts index c4eeff1386..38ee3f8097 100644 --- a/test/integrations/sdkinfo.test.ts +++ b/test/integrations/sdkinfo.test.ts @@ -1,7 +1,7 @@ import type { Event, EventHint, Package } from '@sentry/types'; import { SDK_NAME, SDK_VERSION } from '../../src/js'; -import { SdkInfo } from '../../src/js/integrations'; +import { sdkInfoIntegration } from '../../src/js/integrations'; import { NATIVE } from '../../src/js/wrapper'; let mockedFetchNativeSdkInfo: jest.Mock, []>; @@ -36,7 +36,7 @@ describe('Sdk Info', () => { it('Adds native package and javascript platform to event on iOS', async () => { mockedFetchNativeSdkInfo = jest.fn().mockResolvedValue(mockCocoaPackage); const mockEvent: Event = {}; - const processedEvent = await executeIntegrationFor(mockEvent); + const processedEvent = await processEvent(mockEvent); expect(processedEvent?.sdk?.packages).toEqual(expect.arrayContaining([mockCocoaPackage])); expect(processedEvent?.platform === 'javascript'); @@ -47,7 +47,7 @@ describe('Sdk Info', () => { NATIVE.platform = 'android'; mockedFetchNativeSdkInfo = jest.fn().mockResolvedValue(mockAndroidPackage); const mockEvent: Event = {}; - const processedEvent = await executeIntegrationFor(mockEvent); + const processedEvent = await processEvent(mockEvent); expect(processedEvent?.sdk?.packages).toEqual(expect.not.arrayContaining([mockCocoaPackage])); expect(processedEvent?.platform === 'javascript'); @@ -57,7 +57,7 @@ describe('Sdk Info', () => { it('Does not add any default non native packages', async () => { mockedFetchNativeSdkInfo = jest.fn().mockResolvedValue(null); const mockEvent: Event = {}; - const processedEvent = await executeIntegrationFor(mockEvent); + const processedEvent = await processEvent(mockEvent); expect(processedEvent?.sdk?.packages).toEqual([]); expect(processedEvent?.platform === 'javascript'); @@ -72,7 +72,7 @@ describe('Sdk Info', () => { version: '1.0.0', }, }; - const processedEvent = await executeIntegrationFor(mockEvent); + const processedEvent = await processEvent(mockEvent); expect(processedEvent?.sdk?.name).toEqual('test-sdk'); expect(processedEvent?.sdk?.version).toEqual('1.0.0'); @@ -81,23 +81,14 @@ describe('Sdk Info', () => { it('Does use default sdk name and version', async () => { mockedFetchNativeSdkInfo = jest.fn().mockResolvedValue(null); const mockEvent: Event = {}; - const processedEvent = await executeIntegrationFor(mockEvent); + const processedEvent = await processEvent(mockEvent); expect(processedEvent?.sdk?.name).toEqual(SDK_NAME); expect(processedEvent?.sdk?.version).toEqual(SDK_VERSION); }); }); -function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint = {}): Promise { - const integration = new SdkInfo(); - return new Promise((resolve, reject) => { - integration.setupOnce(async eventProcessor => { - try { - const processedEvent = await eventProcessor(mockedEvent, mockedHint); - resolve(processedEvent); - } catch (e) { - reject(e); - } - }); - }); +function processEvent(mockedEvent: Event, mockedHint: EventHint = {}): Event | null | PromiseLike { + const integration = sdkInfoIntegration(); + return integration.processEvent!(mockedEvent, mockedHint, {} as any); } diff --git a/test/integrations/spotlight.test.ts b/test/integrations/spotlight.test.ts index 4afe47891c..8c3f0c27a2 100644 --- a/test/integrations/spotlight.test.ts +++ b/test/integrations/spotlight.test.ts @@ -1,6 +1,6 @@ import type { HttpRequestEventMap } from '@mswjs/interceptors'; import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest'; -import type { Envelope, Hub } from '@sentry/types'; +import type { Client, Envelope } from '@sentry/types'; import { XMLHttpRequest } from 'xmlhttprequest'; import { Spotlight } from '../../src/js/integrations/spotlight'; @@ -20,17 +20,12 @@ describe('spotlight', () => { }); it('should not change the original envelope', () => { - const mockHub = createMockHub(); + const mockClient = createMockClient(); const spotlight = Spotlight(); - spotlight.setupOnce( - () => {}, - () => mockHub as unknown as Hub, - ); + spotlight.setup?.(mockClient as unknown as Client); - const spotlightBeforeEnvelope = mockHub.getClient().on.mock.calls[0]?.[1] as - | ((envelope: Envelope) => void) - | undefined; + const spotlightBeforeEnvelope = mockClient.on.mock.calls[0]?.[1] as ((envelope: Envelope) => void) | undefined; const originalEnvelopeReference = createMockEnvelope(); spotlightBeforeEnvelope?.(originalEnvelopeReference); @@ -40,17 +35,12 @@ describe('spotlight', () => { }); it('should remove image attachments from spotlight envelope', async () => { - const mockHub = createMockHub(); + const mockClient = createMockClient(); const spotlight = Spotlight(); - spotlight.setupOnce( - () => {}, - () => mockHub as unknown as Hub, - ); + spotlight.setup?.(mockClient as unknown as Client); - const spotlightBeforeEnvelope = mockHub.getClient().on.mock.calls[0]?.[1] as - | ((envelope: Envelope) => void) - | undefined; + const spotlightBeforeEnvelope = mockClient.on.mock.calls[0]?.[1] as ((envelope: Envelope) => void) | undefined; spotlightBeforeEnvelope?.(createMockEnvelope()); @@ -60,14 +50,12 @@ describe('spotlight', () => { }); }); -function createMockHub() { +function createMockClient() { const client = { on: jest.fn(), }; - return { - getClient: jest.fn().mockReturnValue(client), - }; + return client; } function createMockEnvelope(): Envelope { diff --git a/test/integrations/viewhierarchy.test.ts b/test/integrations/viewhierarchy.test.ts index d136bd8403..68cfc0cc7a 100644 --- a/test/integrations/viewhierarchy.test.ts +++ b/test/integrations/viewhierarchy.test.ts @@ -1,16 +1,14 @@ -import type { Event, EventHint } from '@sentry/types'; +import type { Client, Event, EventHint } from '@sentry/types'; -import { ViewHierarchy } from '../../src/js/integrations/viewhierarchy'; +import { viewHierarchyIntegration } from '../../src/js/integrations/viewhierarchy'; import { NATIVE } from '../../src/js/wrapper'; jest.mock('../../src/js/wrapper'); describe('ViewHierarchy', () => { - let integration: ViewHierarchy; let mockEvent: Event; beforeEach(() => { - integration = new ViewHierarchy(); mockEvent = { exception: { values: [ @@ -27,7 +25,7 @@ describe('ViewHierarchy', () => { throw new Error('Test Error'); }); const mockHint: EventHint = {}; - await executeIntegrationFor(mockEvent, mockHint); + await processEvent(mockEvent, mockHint); expect(mockHint).toEqual({}); }); @@ -35,7 +33,7 @@ describe('ViewHierarchy', () => { (NATIVE.fetchViewHierarchy as jest.Mock).mockImplementation(( (() => Promise.resolve(new Uint8Array([]))) )); - await executeIntegrationFor(mockEvent); + await processEvent(mockEvent); expect(mockEvent).toEqual({ exception: { @@ -53,7 +51,7 @@ describe('ViewHierarchy', () => { (() => Promise.resolve(new Uint8Array([1, 2, 3]))) )); const mockHint: EventHint = {}; - await executeIntegrationFor(mockEvent, mockHint); + await processEvent(mockEvent, mockHint); expect(mockHint).toEqual({ attachments: [ @@ -80,7 +78,7 @@ describe('ViewHierarchy', () => { }, ], }; - await executeIntegrationFor(mockEvent, mockHint); + await processEvent(mockEvent, mockHint); expect(mockHint).toEqual({ attachments: [ @@ -104,21 +102,13 @@ describe('ViewHierarchy', () => { (() => Promise.resolve(null)) )); const mockHint: EventHint = {}; - await executeIntegrationFor(mockEvent, mockHint); + await processEvent(mockEvent, mockHint); expect(mockHint).toEqual({}); }); - function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint = {}): Promise { - return new Promise((resolve, reject) => { - integration.setupOnce(async eventProcessor => { - try { - const processedEvent = await eventProcessor(mockedEvent, mockedHint); - resolve(processedEvent); - } catch (e) { - reject(e); - } - }); - }); + function processEvent(mockedEvent: Event, mockedHint: EventHint = {}): Event | null | PromiseLike { + const integration = viewHierarchyIntegration(); + return integration.processEvent!(mockedEvent, mockedHint, {} as Client); } }); diff --git a/test/mocks/client.ts b/test/mocks/client.ts index 69771204e3..0a83cb404d 100644 --- a/test/mocks/client.ts +++ b/test/mocks/client.ts @@ -39,6 +39,7 @@ export class TestClient extends BaseClient { public static sendEventCalled?: (event: Event) => void; public event?: Event; + public hint?: EventHint; public session?: Session; public constructor(options: TestClientOptions) { @@ -73,6 +74,7 @@ export class TestClient extends BaseClient { public sendEvent(event: Event, hint?: EventHint): void { this.event = event; + this.hint = hint; // In real life, this will get deleted as part of envelope creation. delete event.sdkProcessingMetadata; diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 201a192762..00a3d893dd 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -1,88 +1,23 @@ -/** - * @jest-environment jsdom - */ -import { logger } from '@sentry/utils'; - -interface MockedClient { - flush: jest.Mock; -} - -let mockedGetCurrentHubWithScope: jest.Mock; -let mockedGetCurrentHubConfigureScope: jest.Mock; - -jest.mock('@sentry/react', () => { - const actualModule = jest.requireActual('@sentry/react'); - - const mockClient: MockedClient = { - flush: jest.fn(() => Promise.resolve(true)), - }; - - return { - ...actualModule, - getCurrentHub: jest.fn(() => { - mockedGetCurrentHubWithScope = jest.fn(); - mockedGetCurrentHubConfigureScope = jest.fn(); - return { - getClient: jest.fn(() => mockClient), - setTag: jest.fn(), - withScope: mockedGetCurrentHubWithScope, - configureScope: mockedGetCurrentHubConfigureScope, - }; - }), - }; -}); - -jest.mock('@sentry/core', () => { - const originalCore = jest.requireActual('@sentry/core'); - return { - ...originalCore, - initAndBind: jest.fn(), - }; -}); - -jest.mock('@sentry/hub', () => { - const originalHub = jest.requireActual('@sentry/hub'); - return { - ...originalHub, - makeMain: jest.fn(), - }; -}); - -jest.mock('../src/js/scope', () => { - return { - ReactNativeScope: class ReactNativeScopeMock {}, - }; -}); - -jest.mock('../src/js/client', () => { - return { - ReactNativeClient: class ReactNativeClientMock {}, - }; -}); - -import * as mockedWrapper from './mockWrapper'; -jest.mock('../src/js/wrapper', () => mockedWrapper); -jest.mock('../src/js/utils/environment'); - jest.spyOn(logger, 'error'); +jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); +jest.mock('../src/js/utils/environment'); +jest.mock('@sentry/core', () => ({ + ...jest.requireActual('@sentry/core'), + initAndBind: jest.fn(), +})); import { initAndBind } from '@sentry/core'; -import { getCurrentHub, makeFetchTransport } from '@sentry/react'; +import { makeFetchTransport } from '@sentry/react'; import type { BaseTransportOptions, ClientOptions, Integration, Scope } from '@sentry/types'; +import { logger } from '@sentry/utils'; -import type { ReactNativeClientOptions } from '../src/js/options'; -import { configureScope, flush, init, withScope } from '../src/js/sdk'; +import { init, withScope } from '../src/js/sdk'; import { ReactNativeTracing, ReactNavigationInstrumentation } from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environment'; import { NATIVE } from './mockWrapper'; import { firstArg, secondArg } from './testutils'; -const mockedInitAndBind = initAndBind as jest.MockedFunction; -const usedOptions = (): ClientOptions | undefined => { - return mockedInitAndBind.mock.calls[0]?.[1]; -}; - describe('Tests the SDK functionality', () => { beforeEach(() => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => true); @@ -95,21 +30,6 @@ describe('Tests the SDK functionality', () => { describe('init', () => { describe('enableAutoPerformanceTracing', () => { - const usedOptions = (): Integration[] => { - const mockCall = mockedInitAndBind.mock.calls[0]; - - if (mockCall) { - const options = mockCall[1]; - - return options.integrations; - } - return []; - }; - - const autoPerformanceIsEnabled = (): boolean => { - return usedOptions().some(integration => integration.name === ReactNativeTracing.id); - }; - const reactNavigationInstrumentation = (): ReactNativeTracing => { const nav = new ReactNavigationInstrumentation(); return new ReactNativeTracing({ routingInstrumentation: nav }); @@ -163,7 +83,7 @@ describe('Tests the SDK functionality', () => { integrations: [tracing], }); - const options = usedOptions(); + const options = usedIntegrations(); expect(options.filter(integration => integration.name === ReactNativeTracing.id).length).toBe(1); expect(options.some(integration => integration === tracing)).toBe(true); }); @@ -176,42 +96,12 @@ describe('Tests the SDK functionality', () => { defaultIntegrations: [tracing], }); - const options = usedOptions(); + const options = usedIntegrations(); expect(options.filter(integration => integration.name === ReactNativeTracing.id).length).toBe(1); expect(options.some(integration => integration === tracing)).toBe(true); }); }); - describe('flush', () => { - it('Calls flush on the client', async () => { - const mockClient = getMockClient(); - - expect(mockClient).toBeTruthy(); - - if (mockClient) { - const flushResult = await flush(); - - expect(mockClient.flush).toBeCalled(); - expect(flushResult).toBe(true); - } - }); - - it('Returns false if flush failed and logs error', async () => { - const mockClient = getMockClient(); - - expect(mockClient).toBeTruthy(); - if (mockClient) { - mockClient.flush = jest.fn(() => Promise.reject()); - - const flushResult = await flush(); - - expect(mockClient.flush).toBeCalled(); - expect(flushResult).toBe(false); - expect(logger.error).toBeCalledWith('Failed to flush the event queue.'); - } - }); - }); - describe('environment', () => { it('detect development environment', () => { (getDefaultEnvironment as jest.Mock).mockImplementation(() => 'development'); @@ -356,7 +246,7 @@ describe('Tests the SDK functionality', () => { init({ initialScope: mockInitialScope }); expect(() => { - (mockedInitAndBind.mock.calls[0][secondArg].initialScope as (scope: Scope) => Scope)({} as any); + (usedOptions()?.initialScope as (scope: Scope) => Scope)({} as any); }).not.toThrow(); expect(mockInitialScope).toBeCalledTimes(1); }); @@ -368,7 +258,7 @@ describe('Tests the SDK functionality', () => { init({ beforeBreadcrumb: mockBeforeBreadcrumb }); expect(() => { - mockedInitAndBind.mock.calls[0][secondArg].beforeBreadcrumb?.({} as any); + usedOptions()?.beforeBreadcrumb?.({} as any); }).not.toThrow(); expect(mockBeforeBreadcrumb).toBeCalledTimes(1); }); @@ -392,7 +282,7 @@ describe('Tests the SDK functionality', () => { init({ tracesSampler: mockTraceSampler }); expect(() => { - mockedInitAndBind.mock.calls[0][secondArg].tracesSampler?.({} as any); + usedOptions()?.tracesSampler?.({} as any); }).not.toThrow(); expect(mockTraceSampler).toBeCalledTimes(1); }); @@ -404,39 +294,20 @@ describe('Tests the SDK functionality', () => { throw 'Test error'; }); - withScope(mockScopeCallback); - - expect(() => { - (mockedGetCurrentHubWithScope.mock.calls[0][firstArg] as (scope: Scope) => void)({} as any); - }).not.toThrow(); - expect(mockScopeCallback).toBeCalledTimes(1); - }); - }); - - describe('configureScope', () => { - test('configureScope callback does not throw', () => { - const mockScopeCallback = jest.fn(() => { - throw 'Test error'; - }); - - configureScope(mockScopeCallback); - - expect(() => { - (mockedGetCurrentHubConfigureScope.mock.calls[0][firstArg] as (scope: Scope) => void)({} as any); - }).not.toThrow(); + expect(() => withScope(mockScopeCallback)).not.toThrow(); expect(mockScopeCallback).toBeCalledTimes(1); }); }); describe('integrations', () => { it('replaces default integrations', () => { - const mockDefaultIntegration = getMockedIntegration(); + const mockDefaultIntegration = createMockedIntegration(); init({ defaultIntegrations: [mockDefaultIntegration], }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual([mockDefaultIntegration]); }); @@ -444,8 +315,8 @@ describe('Tests the SDK functionality', () => { it('no http client integration by default', () => { init({}); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'HttpClient' })])); }); @@ -455,8 +326,8 @@ describe('Tests the SDK functionality', () => { enableCaptureFailedRequests: true, }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'HttpClient' })])); }); @@ -473,8 +344,8 @@ describe('Tests the SDK functionality', () => { ], }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual( expect.arrayContaining([ @@ -484,14 +355,14 @@ describe('Tests the SDK functionality', () => { }), ]), ); - expect(actualIntegrations.filter(integration => integration.name === 'HttpClient')).toHaveLength(1); + expect(actualIntegrations?.filter(integration => integration.name === 'HttpClient')).toHaveLength(1); }); it('no screenshot integration by default', () => { init({}); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'Screenshot' })])); }); @@ -501,8 +372,8 @@ describe('Tests the SDK functionality', () => { attachScreenshot: true, }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Screenshot' })])); }); @@ -510,8 +381,8 @@ describe('Tests the SDK functionality', () => { it('no view hierarchy integration by default', () => { init({}); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual( expect.not.arrayContaining([expect.objectContaining({ name: 'ViewHierarchy' })]), @@ -523,8 +394,8 @@ describe('Tests the SDK functionality', () => { attachViewHierarchy: true, }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'ViewHierarchy' })])); }); @@ -532,8 +403,8 @@ describe('Tests the SDK functionality', () => { it('no profiling integration by default', () => { init({}); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual( expect.not.arrayContaining([expect.objectContaining({ name: 'HermesProfiling' })]), ); @@ -546,8 +417,8 @@ describe('Tests the SDK functionality', () => { }, }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual( expect.arrayContaining([expect.objectContaining({ name: 'HermesProfiling' })]), ); @@ -556,8 +427,8 @@ describe('Tests the SDK functionality', () => { it('no spotlight integration by default', () => { init({}); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'Spotlight' })])); }); @@ -566,8 +437,8 @@ describe('Tests the SDK functionality', () => { enableSpotlight: true, }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Spotlight' })])); }); @@ -576,42 +447,42 @@ describe('Tests the SDK functionality', () => { defaultIntegrations: false, }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual([]); }); it('merges with passed default integrations', () => { - const mockIntegration = getMockedIntegration(); - const mockDefaultIntegration = getMockedIntegration({ name: 'MockedDefaultIntegration' }); + const mockIntegration = createMockedIntegration(); + const mockDefaultIntegration = createMockedIntegration({ name: 'MockedDefaultIntegration' }); init({ integrations: [mockIntegration], defaultIntegrations: [mockDefaultIntegration], }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.arrayContaining([mockIntegration, mockDefaultIntegration])); // order doesn't matter - expect(actualIntegrations.length).toBe(2); // there should be no extra unexpected integrations + expect(actualIntegrations?.length).toBe(2); // there should be no extra unexpected integrations }); it('merges with default integrations', () => { - const mockIntegration = getMockedIntegration(); + const mockIntegration = createMockedIntegration(); init({ integrations: [mockIntegration], }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.arrayContaining([mockIntegration])); - expect(actualIntegrations.length).toBeGreaterThan(1); // there should be default integrations + the test one + expect(actualIntegrations?.length).toBeGreaterThan(1); // there should be default integrations + the test one }); it('passes default integrations to the function', () => { - const mockIntegration = getMockedIntegration(); + const mockIntegration = createMockedIntegration(); const mockIntegrationFactory = jest.fn((_integrations: Integration[]) => [mockIntegration]); init({ integrations: mockIntegrationFactory, @@ -621,15 +492,15 @@ describe('Tests the SDK functionality', () => { expect(actualPassedIntegrations.length).toBeGreaterThan(0); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual([mockIntegration]); }); it('passes custom default integrations to the function', () => { - const mockIntegration = getMockedIntegration(); - const mockDefaultIntegration = getMockedIntegration({ name: 'MockedDefaultIntegration' }); + const mockIntegration = createMockedIntegration(); + const mockDefaultIntegration = createMockedIntegration({ name: 'MockedDefaultIntegration' }); const mockIntegrationFactory = jest.fn((_integrations: Integration[]) => [mockIntegration]); init({ integrations: mockIntegrationFactory, @@ -640,8 +511,8 @@ describe('Tests the SDK functionality', () => { expect(actualPassedIntegrations).toEqual([mockDefaultIntegration]); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual([mockIntegration]); }); @@ -661,8 +532,8 @@ describe('Tests the SDK functionality', () => { it('adds react default integrations', () => { init({}); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual( expect.arrayContaining([ @@ -678,8 +549,8 @@ describe('Tests the SDK functionality', () => { it('adds all platform default integrations', () => { init({}); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual( expect.arrayContaining([ @@ -695,8 +566,8 @@ describe('Tests the SDK functionality', () => { (notWeb as jest.Mock).mockImplementation(() => false); init({}); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual( expect.arrayContaining([ @@ -717,8 +588,8 @@ describe('Tests the SDK functionality', () => { }, }); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual( expect.not.arrayContaining([expect.objectContaining({ name: 'DeviceContext' })]), @@ -740,21 +611,28 @@ describe('Tests the SDK functionality', () => { (isExpoGo as jest.Mock).mockImplementation(() => true); init({}); - const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; - const actualIntegrations = actualOptions.integrations; + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'ExpoContext' })])); }); }); -function getMockClient(): MockedClient { - const mockClient = getCurrentHub().getClient() as unknown as MockedClient; - return mockClient; -} - -function getMockedIntegration({ name }: { name?: string } = {}): Integration { +function createMockedIntegration({ name }: { name?: string } = {}): Integration { return { name: name ?? 'MockedIntegration', setupOnce: jest.fn(), }; } + +function usedOptions(): ClientOptions | undefined { + return (initAndBind as jest.MockedFunction).mock.calls[0]?.[secondArg]; +} + +function usedIntegrations(): Integration[] { + return usedOptions()?.integrations ?? []; +} + +function autoPerformanceIsEnabled(): boolean { + return usedIntegrations().some(integration => integration.name === ReactNativeTracing.id); +} diff --git a/test/sdk.withclient.test.ts b/test/sdk.withclient.test.ts new file mode 100644 index 0000000000..654b5294e1 --- /dev/null +++ b/test/sdk.withclient.test.ts @@ -0,0 +1,49 @@ +jest.spyOn(logger, 'error'); + +import { setCurrentClient } from '@sentry/core'; +import { logger } from '@sentry/utils'; + +import { configureScope, flush } from '../src/js/sdk'; +import { getDefaultTestClientOptions, TestClient } from './mocks/client'; + +describe('Tests the SDK functionality', () => { + let client: TestClient; + + beforeEach(() => { + client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + + jest.spyOn(client, 'flush'); + }); + + describe('flush', () => { + it('Calls flush on the client', async () => { + const flushResult = await flush(); + + expect(client.flush).toBeCalled(); + expect(flushResult).toBe(true); + }); + + it('Returns false if flush failed and logs error', async () => { + client.flush = jest.fn(() => Promise.reject()); + + const flushResult = await flush(); + + expect(client.flush).toBeCalled(); + expect(flushResult).toBe(false); + expect(logger.error).toBeCalledWith('Failed to flush the event queue.'); + }); + }); + + describe('configureScope', () => { + test('configureScope callback does not throw', () => { + const mockScopeCallback = jest.fn(() => { + throw 'Test error'; + }); + + expect(() => configureScope(mockScopeCallback)).not.toThrow(); + expect(mockScopeCallback).toBeCalledTimes(1); + }); + }); +}); diff --git a/test/touchevents.test.tsx b/test/touchevents.test.tsx index f81a1df8ae..c3a18b246e 100644 --- a/test/touchevents.test.tsx +++ b/test/touchevents.test.tsx @@ -5,32 +5,30 @@ import * as core from '@sentry/core'; import type { SeverityLevel } from '@sentry/types'; import { TouchEventBoundary } from '../src/js/touchevents'; - -jest.mock('@sentry/core'); -jest.mock('../src/js/tracing', () => ({})); +import { getDefaultTestClientOptions,TestClient } from './mocks/client'; describe('TouchEventBoundary._onTouchStart', () => { let addBreadcrumb: jest.SpyInstance; + let addIntegration: jest.SpyInstance; + let client: TestClient; beforeEach(() => { jest.resetAllMocks(); addBreadcrumb = jest.spyOn(core, 'addBreadcrumb'); + + client = new TestClient(getDefaultTestClientOptions()); + core.setCurrentClient(client); + client.init(); }); it('register itself as integration', () => { - const mockAddIntegration = jest.fn(); - (core.getCurrentHub as jest.Mock).mockReturnValue({ - getClient: jest.fn().mockReturnValue({ - addIntegration: mockAddIntegration, - getIntegration: jest.fn(), - }), - }); + addIntegration = jest.spyOn(client, 'addIntegration'); const { defaultProps } = TouchEventBoundary; const boundary = new TouchEventBoundary(defaultProps); boundary.componentDidMount(); - expect(mockAddIntegration).toBeCalledWith(expect.objectContaining({ name: 'TouchEventBoundary' })); + expect(addIntegration).toBeCalledWith(expect.objectContaining({ name: 'TouchEventBoundary' })); }); it('tree without displayName or label is not logged', () => {