diff --git a/src/js/scope.ts b/src/js/scope.ts deleted file mode 100644 index bf8454b52f..0000000000 --- a/src/js/scope.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Scope } from '@sentry/core'; -import type { Attachment, Breadcrumb, User } from '@sentry/types'; - -import { DEFAULT_BREADCRUMB_LEVEL } from './breadcrumb'; -import { convertToNormalizedObject } from './utils/normalize'; -import { NATIVE } from './wrapper'; - -/** - * Extends the scope methods to set scope on the Native SDKs - */ -export class ReactNativeScope extends Scope { - /** - * @inheritDoc - */ - public setUser(user: User | null): this { - NATIVE.setUser(user); - return super.setUser(user); - } - - /** - * @inheritDoc - */ - public setTag(key: string, value: string): this { - NATIVE.setTag(key, value); - return super.setTag(key, value); - } - - /** - * @inheritDoc - */ - public setTags(tags: { [key: string]: string }): this { - // As native only has setTag, we just loop through each tag key. - Object.keys(tags).forEach(key => { - NATIVE.setTag(key, tags[key]); - }); - return super.setTags(tags); - } - - /** - * @inheritDoc - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public setExtras(extras: { [key: string]: any }): this { - Object.keys(extras).forEach(key => { - NATIVE.setExtra(key, extras[key]); - }); - return super.setExtras(extras); - } - - /** - * @inheritDoc - */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any - public setExtra(key: string, extra: any): this { - NATIVE.setExtra(key, extra); - return super.setExtra(key, extra); - } - - /** - * @inheritDoc - */ - public addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this { - const mergedBreadcrumb: Breadcrumb = { - ...breadcrumb, - level: breadcrumb.level || DEFAULT_BREADCRUMB_LEVEL, - data: breadcrumb.data ? convertToNormalizedObject(breadcrumb.data) : undefined, - }; - - super.addBreadcrumb(mergedBreadcrumb, maxBreadcrumbs); - - const finalBreadcrumb = this._breadcrumbs[this._breadcrumbs.length - 1]; - NATIVE.addBreadcrumb(finalBreadcrumb); - return this; - } - - /** - * @inheritDoc - */ - public clearBreadcrumbs(): this { - NATIVE.clearBreadcrumbs(); - return super.clearBreadcrumbs(); - } - - /** - * @inheritDoc - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public setContext(key: string, context: { [key: string]: any } | null): this { - NATIVE.setContext(key, context); - return super.setContext(key, context); - } - - /** - * @inheritDoc - */ - public addAttachment(attachment: Attachment): this { - return super.addAttachment(attachment); - } - - /** - * @inheritDoc - */ - public clearAttachments(): this { - return super.clearAttachments(); - } -} diff --git a/src/js/scopeSync.ts b/src/js/scopeSync.ts new file mode 100644 index 0000000000..770d7c9d43 --- /dev/null +++ b/src/js/scopeSync.ts @@ -0,0 +1,78 @@ +import type { Breadcrumb, Scope } from '@sentry/types'; + +import { DEFAULT_BREADCRUMB_LEVEL } from './breadcrumb'; +import { fillTyped } from './utils/fill'; +import { convertToNormalizedObject } from './utils/normalize'; +import { NATIVE } from './wrapper'; + +/** + * This WeakMap is used to keep track of which scopes have been synced to the native SDKs. + * This ensures that we don't double sync the same scope. + */ +const syncedToNativeMap = new WeakMap(); + +/** + * Hooks into the scope set methods and sync new data added to the given scope with the native SDKs. + */ +export function enableSyncToNative(scope: Scope): void { + if (syncedToNativeMap.has(scope)) { + return; + } + syncedToNativeMap.set(scope, true); + + fillTyped(scope, 'setUser', original => (user): Scope => { + NATIVE.setUser(user); + return original.call(scope, user); + }); + + fillTyped(scope, 'setTag', original => (key, value): Scope => { + NATIVE.setTag(key, value as string); + return original.call(scope, key, value); + }); + + fillTyped(scope, 'setTags', original => (tags): Scope => { + // As native only has setTag, we just loop through each tag key. + Object.keys(tags).forEach(key => { + NATIVE.setTag(key, tags[key] as string); + }); + return original.call(scope, tags); + }); + + fillTyped(scope, 'setExtras', original => (extras): Scope => { + Object.keys(extras).forEach(key => { + NATIVE.setExtra(key, extras[key]); + }); + return original.call(scope, extras); + }); + + fillTyped(scope, 'setExtra', original => (key, value): Scope => { + NATIVE.setExtra(key, value); + return original.call(scope, key, value); + }); + + fillTyped(scope, 'addBreadcrumb', original => (breadcrumb, maxBreadcrumbs): Scope => { + const mergedBreadcrumb: Breadcrumb = { + ...breadcrumb, + level: breadcrumb.level || DEFAULT_BREADCRUMB_LEVEL, + data: breadcrumb.data ? convertToNormalizedObject(breadcrumb.data) : undefined, + }; + + original.call(scope, mergedBreadcrumb, maxBreadcrumbs); + + const finalBreadcrumb = scope.getLastBreadcrumb(); + NATIVE.addBreadcrumb(finalBreadcrumb); + + return scope; + }); + + fillTyped(scope, 'clearBreadcrumbs', original => (): Scope => { + NATIVE.clearBreadcrumbs(); + return original.call(scope); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fillTyped(scope, 'setContext', original => (key: string, context: { [key: string]: any } | null): Scope => { + NATIVE.setContext(key, context); + return original.call(scope, key, context); + }); +} diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index 07576d1f9e..776fe00bff 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -1,10 +1,10 @@ /* eslint-disable complexity */ -import { getClient, getIntegrationsToSetup, initAndBind, withScope as coreWithScope } from '@sentry/core'; +import { getClient, getGlobalScope,getIntegrationsToSetup, getIsolationScope,initAndBind, withScope as coreWithScope } from '@sentry/core'; import { defaultStackParser, makeFetchTransport, } from '@sentry/react'; -import type { Integration, Scope, UserFeedback } from '@sentry/types'; +import type { Integration, Scope,UserFeedback } from '@sentry/types'; import { logger, stackParserFromStackParserOptions } from '@sentry/utils'; import * as React from 'react'; @@ -12,6 +12,7 @@ import { ReactNativeClient } from './client'; import { getDefaultIntegrations } from './integrations/default'; import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options'; import { shouldEnableNativeNagger } from './options'; +import { enableSyncToNative } from './scopeSync'; import { TouchEventBoundary } from './touchevents'; import type { ReactNativeTracing } from './tracing'; import { ReactNativeProfiler } from './tracing'; @@ -43,8 +44,6 @@ export function init(passedOptions: ReactNativeOptions): void { return; } - useEncodePolyfill(); - const maxQueueSize = passedOptions.maxQueueSize // eslint-disable-next-line deprecation/deprecation ?? passedOptions.transportOptions?.bufferSize @@ -53,6 +52,13 @@ export function init(passedOptions: ReactNativeOptions): void { const enableNative = passedOptions.enableNative === undefined || passedOptions.enableNative ? NATIVE.isNativeAvailable() : false; + + useEncodePolyfill(); + if (enableNative) { + enableSyncToNative(getGlobalScope()); + enableSyncToNative(getIsolationScope()); + } + const options: ReactNativeClientOptions = { ...DEFAULT_OPTIONS, ...passedOptions, diff --git a/src/js/utils/fill.ts b/src/js/utils/fill.ts new file mode 100644 index 0000000000..25e034ba9e --- /dev/null +++ b/src/js/utils/fill.ts @@ -0,0 +1,13 @@ +import { fill } from '@sentry/utils'; + +/** + * The same as `import { fill } from '@sentry/utils';` but with explicit types. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function fillTyped( + source: Source, + name: Name, + replacement: (original: Source[Name]) => Source[Name], +): void { + fill(source, name, replacement); +} diff --git a/test/scope.test.ts b/test/scope.test.ts deleted file mode 100644 index cccf772076..0000000000 --- a/test/scope.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Breadcrumb } from '@sentry/types'; - -import { ReactNativeScope } from '../src/js/scope'; -import { NATIVE } from '../src/js/wrapper'; - -jest.mock('../src/js/wrapper'); - -type TestScope = ReactNativeScope & { _breadcrumbs: Breadcrumb[] }; -const createScope = (): TestScope => { - return new ReactNativeScope() as TestScope; -}; - -describe('Scope', () => { - describe('addBreadcrumb', () => { - let scope: TestScope; - let nativeAddBreadcrumbMock: jest.Mock; - - beforeEach(() => { - scope = createScope(); - nativeAddBreadcrumbMock = (NATIVE.addBreadcrumb as jest.Mock).mockImplementationOnce(() => { - return; - }); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('adds default level if no level specified', () => { - const breadcrumb = { - message: 'test', - timestamp: 1234, - }; - scope.addBreadcrumb(breadcrumb); - expect(scope._breadcrumbs).toEqual([ - { - message: 'test', - timestamp: 1234, - level: 'info', - }, - ]); - }); - - it('adds timestamp to breadcrumb without timestamp', () => { - const breadcrumb = { - message: 'test', - }; - scope.addBreadcrumb(breadcrumb); - expect(scope._breadcrumbs).toEqual([expect.objectContaining({ timestamp: expect.any(Number) })]); - }); - - it('passes breadcrumb with timestamp to native', () => { - const breadcrumb = { - message: 'test', - }; - scope.addBreadcrumb(breadcrumb); - expect(nativeAddBreadcrumbMock).toBeCalledWith( - expect.objectContaining({ - timestamp: expect.any(Number), - }), - ); - }); - - test('undefined breadcrumb data is not normalized when passing to the native layer', () => { - const breadcrumb: Breadcrumb = { - data: undefined, - }; - scope.addBreadcrumb(breadcrumb); - expect(nativeAddBreadcrumbMock).toBeCalledWith( - expect.objectContaining({ - data: undefined, - }), - ); - }); - - test('object is normalized when passing to the native layer', () => { - const breadcrumb: Breadcrumb = { - data: { - foo: NaN, - }, - }; - scope.addBreadcrumb(breadcrumb); - expect(nativeAddBreadcrumbMock).toBeCalledWith( - expect.objectContaining({ - data: { foo: '[NaN]' }, - }), - ); - }); - - test('not object data is converted to object', () => { - const breadcrumb: Breadcrumb = { - data: 'foo' as unknown as object, - }; - scope.addBreadcrumb(breadcrumb); - expect(nativeAddBreadcrumbMock).toBeCalledWith( - expect.objectContaining({ - data: { value: 'foo' }, - }), - ); - }); - }); -}); diff --git a/test/scopeSync.test.ts b/test/scopeSync.test.ts new file mode 100644 index 0000000000..e6f7e161fe --- /dev/null +++ b/test/scopeSync.test.ts @@ -0,0 +1,199 @@ +jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); +import * as SentryCore from '@sentry/core'; +import { Scope } from '@sentry/core'; +import type { Breadcrumb } from '@sentry/types'; + +import { enableSyncToNative } from '../src/js/scopeSync'; +import { getDefaultTestClientOptions, TestClient } from './mocks/client'; +import { NATIVE } from './mockWrapper'; + +jest.mock('../src/js/wrapper'); + +describe('ScopeSync', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('scope apis', () => { + let scope: Scope; + + beforeEach(() => { + scope = new Scope(); + enableSyncToNative(scope); + }); + + describe('addBreadcrumb', () => { + it('it only syncs once per scope', () => { + enableSyncToNative(scope); + enableSyncToNative(scope); + + scope.addBreadcrumb({ message: 'test' }); + + expect(NATIVE.addBreadcrumb).toBeCalledTimes(1); + }); + + it('adds default level if no level specified', () => { + const breadcrumb = { + message: 'test', + timestamp: 1234, + }; + scope.addBreadcrumb(breadcrumb); + expect(scope.getLastBreadcrumb()).toEqual({ + message: 'test', + timestamp: 1234, + level: 'info', + }); + }); + + it('adds timestamp to breadcrumb without timestamp', () => { + const breadcrumb = { + message: 'test', + }; + scope.addBreadcrumb(breadcrumb); + expect(scope.getLastBreadcrumb()).toEqual( + expect.objectContaining({ timestamp: expect.any(Number) }), + ); + }); + + it('passes breadcrumb with timestamp to native', () => { + const breadcrumb = { + message: 'test', + }; + scope.addBreadcrumb(breadcrumb); + expect(NATIVE.addBreadcrumb).toBeCalledWith( + expect.objectContaining({ + timestamp: expect.any(Number), + }), + ); + }); + + test('undefined breadcrumb data is not normalized when passing to the native layer', () => { + const breadcrumb: Breadcrumb = { + data: undefined, + }; + scope.addBreadcrumb(breadcrumb); + expect(NATIVE.addBreadcrumb).toBeCalledWith( + expect.objectContaining({ + data: undefined, + }), + ); + }); + + test('object is normalized when passing to the native layer', () => { + const breadcrumb: Breadcrumb = { + data: { + foo: NaN, + }, + }; + scope.addBreadcrumb(breadcrumb); + expect(NATIVE.addBreadcrumb).toBeCalledWith( + expect.objectContaining({ + data: { foo: '[NaN]' }, + }), + ); + }); + + test('not object data is converted to object', () => { + const breadcrumb: Breadcrumb = { + data: 'foo' as unknown as object, + }; + scope.addBreadcrumb(breadcrumb); + expect(NATIVE.addBreadcrumb).toBeCalledWith( + expect.objectContaining({ + data: { value: 'foo' }, + }), + ); + }); + }); + }); + + describe('static apis', () => { + let setUserScopeSpy: jest.SpyInstance; + let setTagScopeSpy: jest.SpyInstance; + let setTagsScopeSpy: jest.SpyInstance; + let setExtraScopeSpy: jest.SpyInstance; + let setExtrasScopeSpy: jest.SpyInstance; + let addBreadcrumbScopeSpy: jest.SpyInstance; + let setContextScopeSpy: jest.SpyInstance; + + beforeAll(() => { + const testScope = SentryCore.getIsolationScope(); + setUserScopeSpy = jest.spyOn(testScope, 'setUser'); + setTagScopeSpy = jest.spyOn(testScope, 'setTag'); + setTagsScopeSpy = jest.spyOn(testScope, 'setTags'); + setExtraScopeSpy = jest.spyOn(testScope, 'setExtra'); + setExtrasScopeSpy = jest.spyOn(testScope, 'setExtras'); + addBreadcrumbScopeSpy = jest.spyOn(testScope, 'addBreadcrumb'); + setContextScopeSpy = jest.spyOn(testScope, 'setContext'); + }); + + beforeEach(() => { + SentryCore.setCurrentClient(new TestClient(getDefaultTestClientOptions())); + enableSyncToNative(SentryCore.getIsolationScope()); + }); + + it('setUser', () => { + expect(SentryCore.getIsolationScope().setUser).not.toBe(setUserScopeSpy); + + const user = { id: '123' }; + SentryCore.setUser(user); + expect(NATIVE.setUser).toHaveBeenCalledExactlyOnceWith({ id: '123' }); + expect(setUserScopeSpy).toHaveBeenCalledExactlyOnceWith({ id: '123' }); + }); + + it('setTag', () => { + expect(SentryCore.getIsolationScope().setTag).not.toBe(setTagScopeSpy); + + SentryCore.setTag('key', 'value'); + expect(NATIVE.setTag).toHaveBeenCalledExactlyOnceWith('key', 'value'); + expect(setTagScopeSpy).toHaveBeenCalledExactlyOnceWith('key', 'value'); + }); + + it('setTags', () => { + expect(SentryCore.getIsolationScope().setTags).not.toBe(setTagsScopeSpy); + + SentryCore.setTags({ key: 'value', second: 'bar' }); + expect(NATIVE.setTag).toBeCalledTimes(2); + expect(NATIVE.setTag).toHaveBeenCalledWith('key', 'value'); + expect(NATIVE.setTag).toHaveBeenCalledWith('second', 'bar'); + expect(setTagsScopeSpy).toHaveBeenCalledExactlyOnceWith({ key: 'value', second: 'bar' }); + }); + + it('setExtra', () => { + expect(SentryCore.getIsolationScope().setExtra).not.toBe(setExtraScopeSpy); + + SentryCore.setExtra('key', 'value'); + expect(NATIVE.setExtra).toHaveBeenCalledExactlyOnceWith('key', 'value'); + expect(setExtraScopeSpy).toHaveBeenCalledExactlyOnceWith('key', 'value'); + }); + + it('setExtras', () => { + expect(SentryCore.getIsolationScope().setExtras).not.toBe(setExtrasScopeSpy); + + SentryCore.setExtras({ key: 'value', second: 'bar' }); + expect(NATIVE.setExtra).toBeCalledTimes(2); + expect(NATIVE.setExtra).toHaveBeenCalledWith('key', 'value'); + expect(NATIVE.setExtra).toHaveBeenCalledWith('second', 'bar'); + expect(setExtrasScopeSpy).toHaveBeenCalledExactlyOnceWith({ key: 'value', second: 'bar' }); + }); + + it('addBreadcrumb', () => { + expect(SentryCore.getIsolationScope().addBreadcrumb).not.toBe(addBreadcrumbScopeSpy); + + SentryCore.addBreadcrumb({ message: 'test' }); + expect(NATIVE.addBreadcrumb).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ message: 'test' })); + expect(addBreadcrumbScopeSpy).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'test' }), + expect.any(Number), + ); + }); + + it('setContext', () => { + expect(SentryCore.getIsolationScope().setContext).not.toBe(setContextScopeSpy); + + SentryCore.setContext('key', { key: 'value' }); + expect(NATIVE.setContext).toHaveBeenCalledExactlyOnceWith('key', { key: 'value' }); + expect(setContextScopeSpy).toHaveBeenCalledExactlyOnceWith('key', { key: 'value' }); + }); + }); +});