diff --git a/CHANGELOG.md b/CHANGELOG.md index b83b106f8a..177f2c5c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ ## Unreleased +### Features + +- Functional integrations ([#3814](https://github.com/getsentry/sentry-react-native/pull/3814)) + + Instead of installing `@sentry/integrations` and creating integrations using the `new` keyword, you can use direct imports of the functional integrations. + + ```js + // Before + import * as Sentry from '@sentry/react-native'; + import { HttpClient } from '@sentry/integrations'; + + Sentry.init({ + integrations: [ + new Sentry.BrowserIntegrations.Dedupe(), + new Sentry.Integration.Screenshot(), + new HttpClient(), + ], + }); + + // After + import * as Sentry from '@sentry/react-native'; + + Sentry.init({ + integrations: [ + Sentry.dedupeIntegration(), + Sentry.screenshotIntegration(), + Sentry.httpClientIntegration(), + ], + }); + ``` + + Note that the `Sentry.BrowserIntegrations`, `Sentry.Integration` and the Class style integrations will be removed in the next major version of the SDK. + ### Fixes - Remove unused `rnpm` config ([#3811](https://github.com/getsentry/sentry-react-native/pull/3811)) diff --git a/src/js/index.ts b/src/js/index.ts index d1d66f0e1d..2864d71e3a 100644 --- a/src/js/index.ts +++ b/src/js/index.ts @@ -64,7 +64,10 @@ export { export { lastEventId } from '@sentry/browser'; import * as Integrations from './integrations'; -import { SDK_NAME, SDK_VERSION } from './version'; + +export * from './integrations/exports'; + +export { SDK_NAME, SDK_VERSION } from './version'; export type { ReactNativeOptions } from './options'; export { ReactNativeClient } from './client'; @@ -100,4 +103,8 @@ export { } from './tracing'; export type { ReactNavigationTransactionContext, TimeToDisplayProps } from './tracing'; -export { Integrations, SDK_NAME, SDK_VERSION }; + +export { + /** @deprecated Import the integration function directly, e.g. `screenshotIntegration()` instead of `new Integrations.Screenshot(). */ + Integrations, +}; diff --git a/src/js/integrations/debugsymbolicator.ts b/src/js/integrations/debugsymbolicator.ts index 1174c79053..de7a18c294 100644 --- a/src/js/integrations/debugsymbolicator.ts +++ b/src/js/integrations/debugsymbolicator.ts @@ -1,10 +1,19 @@ -import type { Event, EventHint, EventProcessor, Hub, Integration, StackFrame as SentryStackFrame } from '@sentry/types'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { + Event, + EventHint, + Integration, + IntegrationClass, + IntegrationFnResult, + 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'; + +const INTEGRATION_NAME = 'DebugSymbolicator'; // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor const INTERNAL_CALLSITES_REGEX = new RegExp(['ReactNativeRenderer-dev\\.js$', 'MessageQueue\\.js$'].join('|')); @@ -20,256 +29,174 @@ 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 = (): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + /* noop */ + }, + processEvent, + }; +}; - if (event.exception) { - symbolicatedFrames && this._replaceExceptionFramesInEvent(event, symbolicatedFrames); - } else if (event.threads) { - // RN JS doesn't have threads - // syntheticException is used for Sentry.captureMessage() threads - symbolicatedFrames && this._replaceThreadFramesInEvent(event, symbolicatedFrames); - } - } +/** + * Tries to symbolicate the JS stack trace on the device. + * + * @deprecated Use `debugSymbolicatorIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const DebugSymbolicator = convertIntegrationFnToClass( + INTEGRATION_NAME, + debugSymbolicatorIntegration, +) as IntegrationClass; + +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'); + // This has been changed in an react-native version so stack is contained in here + const newStack = prettyStack.stack || prettyStack; - const newFrame: SentryStackFrame = { - lineno: frame.lineNumber, - colno: frame.column, - filename: frame.file, - function: frame.methodName, - in_app: inApp, - }; + // 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; - if (inApp) { - await this._addSourceContext(newFrame); - } - - 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(); - } - } - /** - * 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(); + return await convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites); + } catch (error) { + if (error instanceof Error) { + logger.warn(`Unable to symbolicate stack trace: ${error.message}`); } + return null; } +} - /** - * 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..37abe6ba57 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 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 { + breadcrumbsIntegration, + browserApiErrorsIntegration, + browserGlobalHandlersIntegration, + browserLinkedErrorsIntegration, + debugSymbolicatorIntegration, + dedupeIntegration, + deviceContextIntegration, + eventOriginIntegration, + expoContextIntegration, + functionToStringIntegration, + hermesProfilingIntegration, + httpClientIntegration, + httpContextIntegration, + inboundFiltersIntegration, + modulesLoaderIntegration, + nativeLinkedErrorsIntegration, + nativeReleaseIntegration, + reactNativeErrorHandlersIntegration, + reactNativeInfoIntegration, + screenshotIntegration, + sdkInfoIntegration, + spotlightIntegration, + viewHierarchyIntegration, +} from './exports'; import { createReactNativeRewriteFrames } from './rewriteframes'; -import { Screenshot } from './screenshot'; -import { SdkInfo } from './sdkinfo'; -import { Spotlight } from './spotlight'; -import { ViewHierarchy } from './viewhierarchy'; /** * Returns the default ReactNative integrations based on the current environment. @@ -33,47 +42,47 @@ 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()); + integrations.push(hermesProfilingIntegration()); } } @@ -88,16 +97,16 @@ 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) { integrations.push( - Spotlight({ + spotlightIntegration({ sidecarUrl: options.spotlightSidecarUrl, }), ); diff --git a/src/js/integrations/devicecontext.ts b/src/js/integrations/devicecontext.ts index df2834a727..4c7a5a8f5b 100644 --- a/src/js/integrations/devicecontext.ts +++ b/src/js/integrations/devicecontext.ts @@ -1,5 +1,6 @@ /* eslint-disable complexity */ -import type { Event, EventProcessor, Hub, Integration } from '@sentry/types'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { Event, Integration, IntegrationClass, IntegrationFnResult } from '@sentry/types'; import { logger, severityLevelFromString } from '@sentry/utils'; import { AppState } from 'react-native'; @@ -7,94 +8,95 @@ import { breadcrumbFromObject } from '../breadcrumb'; 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'; - - /** - * @inheritDoc - */ - public name: string = DeviceContext.id; +const INTEGRATION_NAME = 'DeviceContext'; - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - addGlobalEventProcessor(async (event: Event) => { - const self = getCurrentHub().getIntegration(DeviceContext); - if (!self) { - return event; - } +/** Load device context from native. */ +export const deviceContextIntegration = (): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + /* noop */ + }, + processEvent, + }; +}; - let native: NativeDeviceContextsResponse | null = null; - try { - native = await NATIVE.fetchNativeDeviceContexts(); - } catch (e) { - logger.log(`Failed to get device context from native: ${e}`); - } +/** + * Load device context from native. + * + * @deprecated Use `deviceContextIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const DeviceContext = convertIntegrationFnToClass( + INTEGRATION_NAME, + deviceContextIntegration, +) as IntegrationClass; - 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..ec9d666d49 100644 --- a/src/js/integrations/eventorigin.ts +++ b/src/js/integrations/eventorigin.ts @@ -1,28 +1,33 @@ -import type { EventProcessor, Integration } from '@sentry/types'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { Event, Integration, IntegrationClass, IntegrationFnResult } from '@sentry/types'; -/** Default EventOrigin instrumentation */ -export class EventOrigin implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'EventOrigin'; - - /** - * @inheritDoc - */ - public name: string = EventOrigin.id; +const INTEGRATION_NAME = 'EventOrigin'; - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { - addGlobalEventProcessor(event => { +/** Default EventOrigin instrumentation */ +export const eventOriginIntegration = (): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + // noop + }, + processEvent: (event: Event) => { event.tags = event.tags ?? {}; event.tags['event.origin'] = 'javascript'; event.tags['event.environment'] = 'javascript'; return event; - }); - } -} + }, + }; +}; + +/** + * Default EventOrigin instrumentation + * + * @deprecated Use `eventOriginIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const EventOrigin = convertIntegrationFnToClass( + INTEGRATION_NAME, + eventOriginIntegration, +) as IntegrationClass; diff --git a/src/js/integrations/expocontext.ts b/src/js/integrations/expocontext.ts index 04944b53bc..adf0c1e89e 100644 --- a/src/js/integrations/expocontext.ts +++ b/src/js/integrations/expocontext.ts @@ -1,44 +1,53 @@ -import type { DeviceContext, Event, EventProcessor, Hub, Integration, OsContext } from '@sentry/types'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { + DeviceContext, + Event, + Integration, + IntegrationClass, + IntegrationFnResult, + 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; +const INTEGRATION_NAME = 'ExpoContext'; - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - addGlobalEventProcessor(async (event: Event) => { - const self = getCurrentHub().getIntegration(ExpoContext); - if (!self) { - return event; - } +/** Load device context from expo modules. */ +export const expoContextIntegration = (): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + // noop + }, + processEvent, + }; +}; - const expoDeviceContext = getExpoDeviceContext(); - if (expoDeviceContext) { - event.contexts = event.contexts || {}; - event.contexts.device = { ...expoDeviceContext, ...event.contexts.device }; - } +/** + * Load device context from expo modules. + * + * @deprecated Use `expoContextIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const ExpoContext = convertIntegrationFnToClass( + INTEGRATION_NAME, + expoContextIntegration, +) as IntegrationClass; - 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/exports.ts b/src/js/integrations/exports.ts new file mode 100644 index 0000000000..b229c3cf50 --- /dev/null +++ b/src/js/integrations/exports.ts @@ -0,0 +1,27 @@ +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 { hermesProfilingIntegration } from '../profiling/integration'; +export { screenshotIntegration } from './screenshot'; +export { viewHierarchyIntegration } from './viewhierarchy'; +export { expoContextIntegration } from './expocontext'; +export { spotlightIntegration } from './spotlight'; + +export { + breadcrumbsIntegration, + browserApiErrorsIntegration, + dedupeIntegration, + functionToStringIntegration, + globalHandlersIntegration as browserGlobalHandlersIntegration, + httpClientIntegration, + httpContextIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration as browserLinkedErrorsIntegration, + rewriteFramesIntegration, +} from '@sentry/react'; diff --git a/src/js/integrations/index.ts b/src/js/integrations/index.ts index 3a8ad303ae..5b9a32f3da 100644 --- a/src/js/integrations/index.ts +++ b/src/js/integrations/index.ts @@ -1,10 +1,16 @@ +// THESE EXPORTS WILL BE REMOVED IN THE NEXT MAJOR RELEASE + export { DebugSymbolicator } from './debugsymbolicator'; export { DeviceContext } from './devicecontext'; export { ReactNativeErrorHandlers } from './reactnativeerrorhandlers'; +export { NativeLinkedErrors } from './nativelinkederrors'; export { Release } from './release'; export { EventOrigin } from './eventorigin'; export { SdkInfo } from './sdkinfo'; export { ReactNativeInfo } from './reactnativeinfo'; export { ModulesLoader } from './modulesloader'; export { HermesProfiling } from '../profiling/integration'; +export { Screenshot } from './screenshot'; +export { ViewHierarchy } from './viewhierarchy'; +export { ExpoContext } from './expocontext'; export { Spotlight } from './spotlight'; diff --git a/src/js/integrations/modulesloader.ts b/src/js/integrations/modulesloader.ts index b3f4da04cc..b49fe164f8 100644 --- a/src/js/integrations/modulesloader.ts +++ b/src/js/integrations/modulesloader.ts @@ -1,43 +1,52 @@ -import type { Event, EventProcessor, Integration } from '@sentry/types'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { Event, Integration, IntegrationClass, IntegrationFnResult } from '@sentry/types'; import { logger } from '@sentry/utils'; import { NATIVE } from '../wrapper'; +const INTEGRATION_NAME = 'ModulesLoader'; + /** Loads runtime JS modules from prepared file. */ -export class ModulesLoader implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'ModulesLoader'; +export const modulesLoaderIntegration = (): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + // noop + }, + processEvent: createProcessEvent(), + }; +}; - /** - * @inheritDoc - */ - public name: string = ModulesLoader.id; +/** + * Loads runtime JS modules from prepared file. + * + * @deprecated Use `modulesLoaderIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const ModulesLoader = convertIntegrationFnToClass( + INTEGRATION_NAME, + modulesLoaderIntegration, +) as IntegrationClass; - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { - let isSetup = false; - let modules: Record | null; +function createProcessEvent(): (event: Event) => Promise { + let isSetup = false; + let modules: Record | null = 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..f35d339f63 100644 --- a/src/js/integrations/nativelinkederrors.ts +++ b/src/js/integrations/nativelinkederrors.ts @@ -1,14 +1,15 @@ import { exceptionFromError } from '@sentry/browser'; +import { convertIntegrationFnToClass } from '@sentry/core'; import type { Client, DebugImage, Event, EventHint, - EventProcessor, Exception, ExtendedError, - Hub, Integration, + IntegrationClass, + IntegrationFnResult, StackFrame, StackParser, } from '@sentry/types'; @@ -17,6 +18,8 @@ import { isInstanceOf, isPlainObject } from '@sentry/utils'; import type { NativeStackFrames } from '../NativeRNSentry'; import { NATIVE } from '../wrapper'; +const INTEGRATION_NAME = 'NativeLinkedErrors'; + const DEFAULT_KEY = 'cause'; const DEFAULT_LIMIT = 5; @@ -28,197 +31,184 @@ 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 = {}): IntegrationFnResult => { + const key = options.key || DEFAULT_KEY; + const limit = options.limit || DEFAULT_LIMIT; + + return { + name: INTEGRATION_NAME, + setupOnce: (): void => { + // noop + }, + preprocessEvent: (event: Event, hint: EventHint, client: Client): void => + preprocessEvent(event, hint, client, limit, key), + }; +}; - /** - * @inheritDoc - */ - public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, _getCurrentHub: () => Hub): void { - /* noop */ +/** + * Processes JS and RN native linked errors. + * + * @deprecated Use `nativeLinkedErrorsIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const NativeLinkedErrors = convertIntegrationFnToClass( + INTEGRATION_NAME, + nativeLinkedErrorsIntegration, +) as IntegrationClass & { + new (options?: Partial): Integration; +}; + +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 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 a5a4e7e487..274c80232d 100644 --- a/src/js/integrations/reactnativeerrorhandlers.ts +++ b/src/js/integrations/reactnativeerrorhandlers.ts @@ -1,11 +1,12 @@ -import { getCurrentHub } from '@sentry/core'; -import type { EventHint, Integration } from '@sentry/types'; +import { captureException, convertIntegrationFnToClass, getClient, getCurrentScope } from '@sentry/core'; +import type { EventHint, Integration, IntegrationClass, IntegrationFnResult, 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'; + +const INTEGRATION_NAME = 'ReactNativeErrorHandlers'; /** ReactNativeErrorHandlers Options */ interface ReactNativeErrorHandlersOptions { @@ -20,262 +21,160 @@ 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 = {}, +): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => + setup({ + onerror: true, + onunhandledrejection: true, + patchGlobalPromise: true, + ...options, + }), + }; +}; + +/** + * ReactNativeErrorHandlers Integration + * + * @deprecated Use `reactNativeErrorHandlersIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const ReactNativeErrorHandlers = convertIntegrationFnToClass( + INTEGRATION_NAME, + reactNativeErrorHandlersIntegration, +) as IntegrationClass & { + new (options?: Partial): Integration; +}; + +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(); - - if (!client) { - logger.error('Sentry client is missing, the error event might be lost.', error); + const client = getClient(); - // If there is no client something is fishy, anyway we call the default handler - defaultHandler(error, isFatal); + if (!client) { + logger.error('Sentry client is missing, the error event might be lost.', error); - return; - } + // If there is no client something is fishy, anyway we call the default handler + defaultHandler(error, isFatal); - const options = client.getOptions(); + return; + } - const hint: EventHint = { - originalException: error, - attachments: scope?.getAttachments(), - }; - const event = await client.eventFromException(error, hint); + const hint: EventHint = { + originalException: error, + attachments: getCurrentScope().getScopeData().attachments, + }; + const event = await client.eventFromException(error, hint); - if (isFatal) { - event.level = 'fatal'; + if (isFatal) { + event.level = 'fatal' as SeverityLevel; - addExceptionMechanism(event, { - handled: false, - type: 'onerror', - }); - } else { - event.level = 'error'; + addExceptionMechanism(event, { + handled: false, + type: 'onerror', + }); + } else { + event.level = 'error'; - addExceptionMechanism(event, { - handled: true, - type: 'generic', - }); - } + 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..a139004b7b 100644 --- a/src/js/integrations/reactnativeinfo.ts +++ b/src/js/integrations/reactnativeinfo.ts @@ -1,4 +1,5 @@ -import type { Context, Event, EventHint, EventProcessor, Integration } from '@sentry/types'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { Context, Event, EventHint, Integration, IntegrationClass, IntegrationFnResult } from '@sentry/types'; import { getExpoGoVersion, @@ -12,6 +13,8 @@ import { } from '../utils/environment'; import type { ReactNativeError } from './debugsymbolicator'; +const INTEGRATION_NAME = 'ReactNativeInfo'; + export interface ReactNativeContext extends Context { js_engine?: string; turbo_module: boolean; @@ -26,71 +29,75 @@ 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 = (): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + // noop + }, + processEvent, + }; +}; - /** - * @inheritDoc - */ - public name: string = ReactNativeInfo.id; +/** + * Loads React Native context at runtime + * + * @deprecated Use `reactNativeInfoIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const ReactNativeInfo = convertIntegrationFnToClass( + INTEGRATION_NAME, + reactNativeInfoIntegration, +) as IntegrationClass; - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { - addGlobalEventProcessor(async (event: Event, hint?: EventHint) => { - const reactNativeError = hint?.originalException ? (hint?.originalException as ReactNativeError) : undefined; +function processEvent(event: Event, hint: EventHint): Event { + 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..56c8c6c7b6 100644 --- a/src/js/integrations/release.ts +++ b/src/js/integrations/release.ts @@ -1,66 +1,81 @@ -import { addGlobalEventProcessor, getCurrentHub } from '@sentry/core'; -import type { Event, Integration } from '@sentry/types'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { + BaseTransportOptions, + Client, + ClientOptions, + Event, + EventHint, + Integration, + IntegrationClass, + IntegrationFnResult, +} from '@sentry/types'; import { NATIVE } from '../wrapper'; +const INTEGRATION_NAME = 'Release'; + /** 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; +export const nativeReleaseIntegration = (): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + // noop + }, + processEvent, + }; +}; - /** - * @inheritDoc - */ - public setupOnce(): void { - addGlobalEventProcessor(async (event: Event) => { - const self = getCurrentHub().getIntegration(Release); - if (!self) { - return event; - } +/** + * Release integration responsible to load release from file. + * + * @deprecated Use `nativeReleaseIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const Release = convertIntegrationFnToClass( + INTEGRATION_NAME, + nativeReleaseIntegration, +) as IntegrationClass; - const options = getCurrentHub().getClient()?.getOptions(); +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}`; - } - if (!event.dist) { - event.dist = `${nativeRelease.build}`; - } - } - } catch (_Oo) { - // Something went wrong, we just continue + try { + const nativeRelease = await NATIVE.fetchNativeRelease(); + if (nativeRelease) { + if (!event.release) { + event.release = `${nativeRelease.id}@${nativeRelease.version}+${nativeRelease.build}`; } - - return event; - }); + if (!event.dist) { + event.dist = `${nativeRelease.build}`; + } + } + } 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 a52d15276f..6c59a195ac 100644 --- a/src/js/integrations/screenshot.ts +++ b/src/js/integrations/screenshot.ts @@ -1,62 +1,46 @@ -import { getClient } from '@sentry/core'; -import type { Event, EventHint, EventProcessor, Integration } from '@sentry/types'; -import { resolvedSyncPromise } from '@sentry/utils'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { Event, EventHint, Integration, IntegrationClass, IntegrationFnResult } from '@sentry/types'; import type { ReactNativeClient } from '../client'; import type { Screenshot as ScreenshotAttachment } from '../wrapper'; import { NATIVE } from '../wrapper'; +const INTEGRATION_NAME = 'Screenshot'; + /** 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 = (): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + // noop + }, + processEvent, + }; +}; + +/** + * Adds screenshots to error events + * + * @deprecated Use `screenshotIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const Screenshot = convertIntegrationFnToClass( + INTEGRATION_NAME, + screenshotIntegration, +) as IntegrationClass; + +async function processEvent(event: Event, hint: EventHint, client: ReactNativeClient): Promise { + const options = client.getOptions(); + + const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + if (!hasException || options?.beforeScreenshot?.(event, hint) === false) { + return event; } - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { - const options = getClient()?.getOptions(); - - addGlobalEventProcessor(async (event: Event, hint: EventHint) => { - const hasException = event.exception && event.exception.values && event.exception.values.length > 0; - if (!hasException || options?.beforeScreenshot?.(event, hint) === false) { - 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..62ad0a3b0e 100644 --- a/src/js/integrations/sdkinfo.ts +++ b/src/js/integrations/sdkinfo.ts @@ -1,10 +1,20 @@ -import type { EventProcessor, Integration, Package, SdkInfo as SdkInfoType } from '@sentry/types'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { + Event, + Integration, + IntegrationClass, + IntegrationFnResult, + Package, + SdkInfo as SdkInfoType, +} from '@sentry/types'; import { logger } from '@sentry/utils'; import { isExpoGo, notWeb } from '../utils/environment'; import { SDK_NAME, SDK_PACKAGE_NAME, SDK_VERSION } from '../version'; import { NATIVE } from '../wrapper'; +const INTEGRATION_NAME = 'SdkInfo'; + type DefaultSdkInfo = Pick, 'name' | 'packages' | 'version'>; export const defaultSdkInfo: DefaultSdkInfo = { @@ -19,50 +29,67 @@ export const defaultSdkInfo: DefaultSdkInfo = { }; /** Default SdkInfo instrumentation */ -export class SdkInfo implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'SdkInfo'; +export const sdkInfoIntegration = (): IntegrationFnResult => { + const fetchNativeSdkInfo = createCachedFetchNativeSdkInfo(); - /** - * @inheritDoc - */ - public name: string = SdkInfo.id; + return { + name: INTEGRATION_NAME, + setupOnce: () => { + // noop + }, + processEvent: (event: Event) => processEvent(event, fetchNativeSdkInfo), + }; +}; - private _nativeSdkPackage: Package | null = null; +/** + * Default SdkInfo instrumentation + * + * @deprecated Use `sdkInfoIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const SdkInfo = convertIntegrationFnToClass( + INTEGRATION_NAME, + sdkInfoIntegration, +) as IntegrationClass; - /** - * @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); - } - } - } +async function processEvent(event: Event, fetchNativeSdkInfo: () => Promise): Promise { + const nativeSdkPackage = await fetchNativeSdkInfo(); - 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]) || []), - ]; + 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]) || []), + ]; + + return event; +} - return event; - }); +function createCachedFetchNativeSdkInfo(): () => Promise { + if (!notWeb() || isExpoGo()) { + return () => { + return Promise.resolve(null); + }; } + + let isCached: boolean = false; + let nativeSdkPackageCache: Package | null = null; + + return async () => { + 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..8a07806e6f 100644 --- a/src/js/integrations/spotlight.ts +++ b/src/js/integrations/spotlight.ts @@ -1,4 +1,11 @@ -import type { Client, Envelope, EventProcessor, Integration } from '@sentry/types'; +import type { + BaseTransportOptions, + Client, + ClientOptions, + Envelope, + Integration, + IntegrationFnResult, +} from '@sentry/types'; import { logger, serializeEnvelope } from '@sentry/utils'; import { makeUtf8TextEncoder } from '../transports/TextEncoder'; @@ -20,25 +27,33 @@ type SpotlightReactNativeIntegrationOptions = { * * Learn more about spotlight at https://spotlightjs.com */ -export function Spotlight({ +export function spotlightIntegration({ sidecarUrl = getDefaultSidecarUrl(), -}: SpotlightReactNativeIntegrationOptions = {}): Integration { +}: SpotlightReactNativeIntegrationOptions = {}): IntegrationFnResult { logger.info('[Spotlight] Using Sidecar URL', sidecarUrl); 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); }, }; } +/** + * Use this integration to send errors and transactions to Spotlight. + * + * Learn more about spotlight at https://spotlightjs.com + * + * @deprecated Use `spotlightIntegration()` instead. + */ +export const Spotlight = spotlightIntegration as (...args: Parameters) => Integration; + function setup(client: Client, sidecarUrl: string): void { sendEnvelopesToSidecar(client, sidecarUrl); } diff --git a/src/js/integrations/viewhierarchy.ts b/src/js/integrations/viewhierarchy.ts index e84a113c63..9804ea8fff 100644 --- a/src/js/integrations/viewhierarchy.ts +++ b/src/js/integrations/viewhierarchy.ts @@ -1,55 +1,61 @@ -import type { Event, EventHint, EventProcessor, Integration } from '@sentry/types'; -import type { AttachmentType } from '@sentry/types/types/attachment'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { Attachment, Event, EventHint, Integration, IntegrationClass, IntegrationFnResult } from '@sentry/types'; import { logger } from '@sentry/utils'; import { NATIVE } from '../wrapper'; +const filename: string = 'view-hierarchy.json'; +const contentType: string = 'application/json'; +const attachmentType = 'event.view_hierarchy' as Attachment['attachmentType']; + +const INTEGRATION_NAME = 'ViewHierarchy'; + /** 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: AttachmentType = 'event.view_hierarchy' as AttachmentType; - - /** - * @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; - } - - let viewHierarchy: Uint8Array | null = null; - try { - viewHierarchy = await NATIVE.fetchViewHierarchy(); - } catch (e) { - logger.error('Failed to get view hierarchy from native.', e); - } - - if (viewHierarchy) { - hint.attachments = [ - { - filename: ViewHierarchy._fileName, - contentType: ViewHierarchy._contentType, - attachmentType: ViewHierarchy._attachmentType, - data: viewHierarchy, - }, - ...(hint?.attachments || []), - ]; - } - - return event; - }); +export const viewHierarchyIntegration = (): IntegrationFnResult => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + // noop + }, + processEvent, + }; +}; + +/** + * Adds ViewHierarchy to error events + * + * @deprecated Use `viewHierarchyIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const ViewHierarchy = convertIntegrationFnToClass( + INTEGRATION_NAME, + viewHierarchyIntegration, +) as IntegrationClass; + +async function processEvent(event: Event, hint: EventHint): Promise { + const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + if (!hasException) { + return event; + } + + let viewHierarchy: Uint8Array | null = null; + try { + viewHierarchy = await NATIVE.fetchViewHierarchy(); + } catch (e) { + logger.error('Failed to get view hierarchy from native.', e); } + + if (viewHierarchy) { + hint.attachments = [ + { + filename, + contentType, + attachmentType, + data: viewHierarchy, + }, + ...(hint?.attachments || []), + ]; + } + + return event; } diff --git a/src/js/profiling/integration.ts b/src/js/profiling/integration.ts index b82ecfad58..8fb6b7bcef 100644 --- a/src/js/profiling/integration.ts +++ b/src/js/profiling/integration.ts @@ -1,7 +1,14 @@ /* eslint-disable complexity */ -import type { Hub } from '@sentry/core'; -import { getActiveTransaction } from '@sentry/core'; -import type { Envelope, Event, EventProcessor, Integration, ThreadCpuProfile, Transaction } from '@sentry/types'; +import { convertIntegrationFnToClass, getActiveTransaction, getClient, getCurrentHub } from '@sentry/core'; +import type { + Envelope, + Event, + Integration, + IntegrationClass, + IntegrationFn, + ThreadCpuProfile, + Transaction, +} from '@sentry/types'; import { logger, uuid4 } from '@sentry/utils'; import { Platform } from 'react-native'; @@ -19,6 +26,8 @@ import { findProfiledTransactionsFromEnvelope, } from './utils'; +const INTEGRATION_NAME = 'HermesProfiling'; + const MS_TO_NS: number = 1e6; /** @@ -26,48 +35,31 @@ const MS_TO_NS: number = 1e6; * * @experimental */ -export class HermesProfiling implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'HermesProfiling'; - - /** - * @inheritDoc - */ - public name: string = HermesProfiling.id; - - private _getCurrentHub?: () => Hub; - - private _currentProfile: +export const hermesProfilingIntegration: IntegrationFn = () => { + let _currentProfile: | { profile_id: string; startTimestampNs: number; } | undefined; + let _currentProfileTimeout: number | undefined; - private _currentProfileTimeout: number | undefined; - - /** - * @inheritDoc - */ - public setupOnce(_: (e: EventProcessor) => void, getCurrentHub: () => Hub): void { + const setupOnce = (): void => { if (!isHermesEnabled()) { logger.log('[Profiling] Hermes is not enabled, not adding profiling integration.'); return; } - this._getCurrentHub = getCurrentHub; - const client = getCurrentHub().getClient(); + const client = getClient(); if (!client || typeof client.on !== 'function') { return; } - this._startCurrentProfileForActiveTransaction(); - client.on('startTransaction', this._startCurrentProfile); + _startCurrentProfileForActiveTransaction(); + client.on('startTransaction', _startCurrentProfile); - client.on('finishTransaction', this._finishCurrentProfile); + client.on('finishTransaction', _finishCurrentProfile); client.on('beforeEnvelope', (envelope: Envelope) => { if (!PROFILE_QUEUE.size()) { @@ -82,42 +74,42 @@ export class HermesProfiling implements Integration { const profilesToAddToEnvelope: ProfileEvent[] = []; for (const profiledTransaction of profiledTransactions) { - const profile = this._createProfileEventFor(profiledTransaction); + const profile = _createProfileEventFor(profiledTransaction); if (profile) { profilesToAddToEnvelope.push(profile); } } addProfilesToEnvelope(envelope, profilesToAddToEnvelope); }); - } + }; - private _startCurrentProfileForActiveTransaction = (): void => { - if (this._currentProfile) { + const _startCurrentProfileForActiveTransaction = (): void => { + if (_currentProfile) { return; } - const transaction = this._getCurrentHub && getActiveTransaction(this._getCurrentHub()); - transaction && this._startCurrentProfile(transaction); + const transaction = getActiveTransaction(getCurrentHub()); + transaction && _startCurrentProfile(transaction); }; - private _startCurrentProfile = (transaction: Transaction): void => { - this._finishCurrentProfile(); + const _startCurrentProfile = (transaction: Transaction): void => { + _finishCurrentProfile(); - const shouldStartProfiling = this._shouldStartProfiling(transaction); + const shouldStartProfiling = _shouldStartProfiling(transaction); if (!shouldStartProfiling) { return; } - this._currentProfileTimeout = setTimeout(this._finishCurrentProfile, MAX_PROFILE_DURATION_MS); - this._startNewProfile(transaction); + _currentProfileTimeout = setTimeout(_finishCurrentProfile, MAX_PROFILE_DURATION_MS); + _startNewProfile(transaction); }; - private _shouldStartProfiling = (transaction: Transaction): boolean => { + const _shouldStartProfiling = (transaction: Transaction): boolean => { if (!transaction.sampled) { logger.log('[Profiling] Transaction is not sampled, skipping profiling'); return false; } - const client = this._getCurrentHub && this._getCurrentHub().getClient(); + const client = getClient(); const options = client && client.getOptions(); const profilesSampleRate = @@ -141,45 +133,45 @@ export class HermesProfiling implements Integration { /** * Starts a new profile and links it to the transaction. */ - private _startNewProfile = (transaction: Transaction): void => { + const _startNewProfile = (transaction: Transaction): void => { const profileStartTimestampNs = startProfiling(); if (!profileStartTimestampNs) { return; } - this._currentProfile = { + _currentProfile = { profile_id: uuid4(), startTimestampNs: profileStartTimestampNs, }; - transaction.setContext('profile', { profile_id: this._currentProfile.profile_id }); + transaction.setContext('profile', { profile_id: _currentProfile.profile_id }); // @ts-expect-error profile_id is not part of the metadata type - transaction.setMetadata({ profile_id: this._currentProfile.profile_id }); - logger.log('[Profiling] started profiling: ', this._currentProfile.profile_id); + transaction.setMetadata({ profile_id: _currentProfile.profile_id }); + logger.log('[Profiling] started profiling: ', _currentProfile.profile_id); }; /** * Stops profiling and adds the profile to the queue to be processed on beforeEnvelope. */ - private _finishCurrentProfile = (): void => { - this._clearCurrentProfileTimeout(); - if (this._currentProfile === undefined) { + const _finishCurrentProfile = (): void => { + _clearCurrentProfileTimeout(); + if (_currentProfile === undefined) { return; } - const profile = stopProfiling(this._currentProfile.startTimestampNs); + const profile = stopProfiling(_currentProfile.startTimestampNs); if (!profile) { logger.warn('[Profiling] Stop failed. Cleaning up...'); - this._currentProfile = undefined; + _currentProfile = undefined; return; } - PROFILE_QUEUE.add(this._currentProfile.profile_id, profile); + PROFILE_QUEUE.add(_currentProfile.profile_id, profile); - logger.log('[Profiling] finished profiling: ', this._currentProfile.profile_id); - this._currentProfile = undefined; + logger.log('[Profiling] finished profiling: ', _currentProfile.profile_id); + _currentProfile = undefined; }; - private _createProfileEventFor = (profiledTransaction: Event): ProfileEvent | null => { + const _createProfileEventFor = (profiledTransaction: Event): ProfileEvent | null => { const profile_id = profiledTransaction?.contexts?.['profile']?.['profile_id']; if (typeof profile_id !== 'string') { @@ -206,11 +198,27 @@ export class HermesProfiling implements Integration { return profileWithEvent; }; - private _clearCurrentProfileTimeout = (): void => { - this._currentProfileTimeout !== undefined && clearTimeout(this._currentProfileTimeout); - this._currentProfileTimeout = undefined; + const _clearCurrentProfileTimeout = (): void => { + _currentProfileTimeout !== undefined && clearTimeout(_currentProfileTimeout); + _currentProfileTimeout = undefined; }; -} + + return { + name: INTEGRATION_NAME, + setupOnce, + }; +}; + +/** + * Profiling integration creates a profile for each transaction and adds it to the event envelope. + * + * @deprecated Use `hermesProfilingIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const HermesProfiling = convertIntegrationFnToClass( + INTEGRATION_NAME, + hermesProfilingIntegration, +) as IntegrationClass; /** * Starts Profilers and returns the timestamp when profiling started in nanoseconds. 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..ff46e5f3c1 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/devicecontext'; 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..017d75129c 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/eventorigin'; 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..73f2b08451 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/modulesloader'; 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 59dcd35c55..69932a9318 100644 --- a/test/integrations/reactnativeerrorhandlers.test.ts +++ b/test/integrations/reactnativeerrorhandlers.test.ts @@ -1,23 +1,24 @@ +jest.mock('../../src/js/integrations/reactnativeerrorhandlersutils'); + import { setCurrentClient } from '@sentry/core'; -import type { ExtendedError, Integration, SeverityLevel } from '@sentry/types'; +import type { ExtendedError, SeverityLevel } from '@sentry/types'; -import { ReactNativeErrorHandlers } from '../../src/js/integrations/reactnativeerrorhandlers'; +import { reactNativeErrorHandlersIntegration } from '../../src/js/integrations/reactnativeerrorhandlers'; +import { requireRejectionTracking } from '../../src/js/integrations/reactnativeerrorhandlersutils'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; -interface MockedReactNativeErrorHandlers extends Integration { - _loadRejectionTracking: jest.Mock< - { - disable: jest.Mock; - enable: jest.Mock; - }, - [] - >; -} - 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()); @@ -39,7 +40,7 @@ describe('ReactNativeErrorHandlers', () => { errorHandlerCallback = _callback as typeof errorHandlerCallback; }); - const integration = new ReactNativeErrorHandlers(); + const integration = reactNativeErrorHandlersIntegration(); integration.setupOnce(); @@ -80,13 +81,7 @@ describe('ReactNativeErrorHandlers', () => { describe('onUnhandledRejection', () => { test('unhandled rejected promise is captured with synthetical error', async () => { - const integration = new ReactNativeErrorHandlers(); - const mockDisable = jest.fn(); - const mockEnable = jest.fn(); - (integration as unknown as MockedReactNativeErrorHandlers)._loadRejectionTracking = jest.fn(() => ({ - disable: mockDisable, - enable: mockEnable, - })); + const integration = reactNativeErrorHandlersIntegration(); integration.setupOnce(); const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; @@ -108,13 +103,7 @@ describe('ReactNativeErrorHandlers', () => { }); test('error like unhandled rejected promise is captured without synthetical error', async () => { - const integration = new ReactNativeErrorHandlers(); - const mockDisable = jest.fn(); - const mockEnable = jest.fn(); - (integration as unknown as MockedReactNativeErrorHandlers)._loadRejectionTracking = jest.fn(() => ({ - disable: mockDisable, - enable: mockEnable, - })); + const integration = reactNativeErrorHandlersIntegration(); integration.setupOnce(); const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; 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..71ba72888f 100644 --- a/test/integrations/release.test.ts +++ b/test/integrations/release.test.ts @@ -1,26 +1,6 @@ -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: { @@ -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,12 @@ describe('Tests the Release integration', () => { }, }, {}, + { + getOptions: () => ({ + dist: 'options_dist', + release: 'options_release', + }), + } as Client, ); expect(event?.release).toBe('sentry_release'); diff --git a/test/integrations/sdkinfo.test.ts b/test/integrations/sdkinfo.test.ts index c4eeff1386..71d19d0ec7 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/sdkinfo'; 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/profiling/integration.test.ts b/test/profiling/integration.test.ts index ec4b1ce7c9..b9acc58c3d 100644 --- a/test/profiling/integration.test.ts +++ b/test/profiling/integration.test.ts @@ -7,9 +7,9 @@ import { getCurrentHub } from '@sentry/core'; import type { Envelope, Event, Profile, ThreadCpuProfile, Transaction, Transport } from '@sentry/types'; import * as Sentry from '../../src/js'; -import { HermesProfiling } from '../../src/js/integrations'; import type { NativeDeviceContextsResponse } from '../../src/js/NativeRNSentry'; import { getDebugMetadata } from '../../src/js/profiling/debugid'; +import { hermesProfilingIntegration } from '../../src/js/profiling/integration'; import type { AndroidProfileEvent } from '../../src/js/profiling/types'; import { getDefaultEnvironment, isHermesEnabled, notWeb } from '../../src/js/utils/environment'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; @@ -62,7 +62,7 @@ describe('profiling integration', () => { }); getCurrentHub().getScope()?.setSpan(transaction); - getCurrentHub().getClient()?.addIntegration?.(new HermesProfiling()); + getCurrentHub().getClient()?.addIntegration?.(hermesProfilingIntegration()); transaction.finish(); jest.runAllTimers(); @@ -336,36 +336,23 @@ describe('profiling integration', () => { }); test('profile timeout is reset when transaction is finished', () => { - const integration = getCurrentHermesProfilingIntegration(); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); const transaction: Transaction = Sentry.startTransaction({ name: 'test-name', }); - const timeoutAfterProfileStarted = integration._currentProfileTimeout; + const timeoutAfterProfileStarted = setTimeoutSpy.mock.results[0].value; jest.advanceTimersByTime(40 * 1e6); transaction.finish(); - const timeoutAfterProfileFinished = integration._currentProfileTimeout; + expect(clearTimeoutSpy).toBeCalledWith(timeoutAfterProfileStarted); jest.runAllTimers(); - - expect(timeoutAfterProfileStarted).toBeDefined(); - expect(timeoutAfterProfileFinished).toBeUndefined(); }); }); }); -type TestHermesIntegration = Omit & { - _currentProfileTimeout: number | undefined; -}; -function getCurrentHermesProfilingIntegration(): TestHermesIntegration { - const integration = Sentry.getCurrentHub().getClient()?.getIntegration(HermesProfiling); - if (!integration) { - throw new Error('HermesProfiling integration is not installed'); - } - return integration as unknown as TestHermesIntegration; -} - function initTestClient( testOptions: { withProfiling?: boolean; 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', () => {