diff --git a/CHANGELOG.md b/CHANGELOG.md index 68dc4037b1..a08f07e63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Current screen is now repoted on the error's app context ([#3339](https://github.com/getsentry/sentry-react-native/pull/3339)) - Export New JS Performance API ([#3371](https://github.com/getsentry/sentry-react-native/pull/3371)) ```js diff --git a/src/js/integrations/devicecontext.ts b/src/js/integrations/devicecontext.ts index b75955479b..df2834a727 100644 --- a/src/js/integrations/devicecontext.ts +++ b/src/js/integrations/devicecontext.ts @@ -55,6 +55,9 @@ export class DeviceContext implements Integration { } if (nativeContexts) { event.contexts = { ...nativeContexts, ...event.contexts }; + if (nativeContexts.app) { + event.contexts.app = { ...nativeContexts.app, ...event.contexts.app }; + } } const nativeTags = native.tags; diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 7a1ec1c49f..85dedf3e31 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -3,7 +3,13 @@ import type { RequestInstrumentationOptions } from '@sentry/browser'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from '@sentry/browser'; import type { Hub, IdleTransaction, Transaction } from '@sentry/core'; import { getActiveTransaction, getCurrentHub, startIdleTransaction } from '@sentry/core'; -import type { EventProcessor, Integration, Transaction as TransactionType, TransactionContext } from '@sentry/types'; +import type { + Event, + EventProcessor, + Integration, + Transaction as TransactionType, + TransactionContext, +} from '@sentry/types'; import { logger } from '@sentry/utils'; import { APP_START_COLD, APP_START_WARM } from '../measurements'; @@ -140,6 +146,7 @@ export class ReactNativeTracing implements Integration { private _currentRoute?: string; private _hasSetTracePropagationTargets: boolean; private _hasSetTracingOrigins: boolean; + private _currentViewName: string | undefined; public constructor(options: Partial = {}) { this._hasSetTracePropagationTargets = !!( @@ -255,6 +262,8 @@ export class ReactNativeTracing implements Integration { logger.log('[ReactNativeTracing] Not instrumenting route changes as routingInstrumentation has not been set.'); } + addGlobalEventProcessor(this._getCurrentViewEventProcessor.bind(this)); + instrumentOutgoingRequests({ traceFetch, traceXHR, @@ -350,6 +359,17 @@ export class ReactNativeTracing implements Integration { return this._inflightInteractionTransaction; } + /** + * Sets the current view name into the app context. + * @param event Le event. + */ + private _getCurrentViewEventProcessor(event: Event): Event { + if (event.contexts && this._currentViewName) { + event.contexts.app = { view_names: [this._currentViewName], ...event.contexts.app }; + } + return event; + } + /** * Returns the App Start Duration in Milliseconds. Also returns undefined if not able do * define the duration. @@ -455,6 +475,10 @@ export class ReactNativeTracing implements Integration { }); } + this._currentViewName = context.name; + /** + * @deprecated tag routing.route.name will be removed in the future. + */ scope.setTag('routing.route.name', context.name); }); } diff --git a/test/integrations/devicecontext.test.ts b/test/integrations/devicecontext.test.ts index 09f514f7a5..a2644ca58a 100644 --- a/test/integrations/devicecontext.test.ts +++ b/test/integrations/devicecontext.test.ts @@ -43,6 +43,47 @@ describe('Device Context Integration', () => { ).expectEvent.toStrictEqualMockEvent(); }); + it('do not overwrite event app context', async () => { + ( + await executeIntegrationWith({ + nativeContexts: { app: { view_names: ['native view'] } }, + mockEvent: { contexts: { app: { view_names: ['Home'] } } }, + }) + ).expectEvent.toStrictEqualMockEvent(); + }); + + it('merge event context app', async () => { + const { processedEvent } = await executeIntegrationWith({ + nativeContexts: { contexts: { app: { native: 'value' } } }, + mockEvent: { contexts: { app: { event_app: 'value' } } }, + }); + expect(processedEvent).toStrictEqual({ + contexts: { + app: { + event_app: 'value', + native: 'value', + }, + }, + }); + }); + + it('merge event context app even when event app doesnt exist', async () => { + const { processedEvent } = await executeIntegrationWith({ + nativeContexts: { contexts: { app: { native: 'value' } } }, + mockEvent: { contexts: { keyContext: { key: 'value' } } }, + }); + expect(processedEvent).toStrictEqual({ + contexts: { + keyContext: { + key: 'value', + }, + app: { + native: 'value', + }, + }, + }); + }); + it('merge event and native contexts', async () => { const { processedEvent } = await executeIntegrationWith({ nativeContexts: { contexts: { duplicate: { context: 'native-value' }, native: { context: 'value' } } }, diff --git a/test/tracing/gesturetracing.test.ts b/test/tracing/gesturetracing.test.ts index 3e40681e49..fcd9c15db2 100644 --- a/test/tracing/gesturetracing.test.ts +++ b/test/tracing/gesturetracing.test.ts @@ -40,6 +40,9 @@ const getMockScope = () => { setTag(_tag: unknown) { // Placeholder }, + setContext(_context: unknown) { + // Placeholder + }, addBreadcrumb(_breadcrumb: unknown) { // Placeholder }, diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 2c0995c826..450c0993cd 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -66,6 +66,9 @@ const getMockScope = () => { setTag(_tag: any) { // Placeholder }, + setContext(_context: any) { + // Placeholder + }, addBreadcrumb(_breadcrumb: any) { // Placeholder }, @@ -95,7 +98,7 @@ const getMockHub = () => { return mockHub; }; -import type { Scope } from '@sentry/types'; +import type { Event, Scope } from '@sentry/types'; import type { AppState, AppStateStatus } from 'react-native'; import { APP_START_COLD, APP_START_WARM } from '../../src/js/measurements'; @@ -678,15 +681,16 @@ describe('ReactNativeTracing', () => { describe('Routing Instrumentation', () => { describe('_onConfirmRoute', () => { - it('Sets tag and adds breadcrumb', () => { + it('Sets app context, tag and adds breadcrumb', () => { const routing = new RoutingInstrumentation(); const integration = new ReactNativeTracing({ routingInstrumentation: routing, }); - + let mockEvent: Event | null = { contexts: {} }; const mockScope = { addBreadcrumb: jest.fn(), setTag: jest.fn(), + setContext: jest.fn(), // Not relevant to test setSpan: () => {}, @@ -724,6 +728,20 @@ describe('ReactNativeTracing', () => { }; routing.onRouteWillChange(routeContext); + mockEvent = integration['_getCurrentViewEventProcessor'](mockEvent); + + if (!mockEvent) { + throw new Error('mockEvent was not defined'); + } + expect(mockEvent.contexts?.app).toBeDefined(); + // Only required to mark app as defined. + if (mockEvent.contexts?.app) { + expect(mockEvent.contexts.app['view_names']).toEqual([routeContext.name]); + } + + /** + * @deprecated tag routing.route.name will be removed in the future. + */ expect(mockScope.setTag).toBeCalledWith('routing.route.name', routeContext.name); expect(mockScope.addBreadcrumb).toBeCalledWith({ type: 'navigation', @@ -735,6 +753,56 @@ describe('ReactNativeTracing', () => { }, }); }); + + describe('View Names event processor', () => { + it('Do not overwrite event app context', () => { + const routing = new RoutingInstrumentation(); + const integration = new ReactNativeTracing({ + routingInstrumentation: routing, + }); + + const expectedRouteName = 'Route'; + const event: Event = { contexts: { app: { appKey: 'value' } } }; + const expectedEvent: Event = { contexts: { app: { appKey: 'value', view_names: [expectedRouteName] } } }; + + // @ts-expect-error only for testing. + integration._currentViewName = expectedRouteName; + const processedEvent = integration['_getCurrentViewEventProcessor'](event); + + expect(processedEvent).toEqual(expectedEvent); + }); + + it('Do not add view_names if context is undefined', () => { + const routing = new RoutingInstrumentation(); + const integration = new ReactNativeTracing({ + routingInstrumentation: routing, + }); + + const expectedRouteName = 'Route'; + const event: Event = { release: 'value' }; + const expectedEvent: Event = { release: 'value' }; + + // @ts-expect-error only for testing. + integration._currentViewName = expectedRouteName; + const processedEvent = integration['_getCurrentViewEventProcessor'](event); + + expect(processedEvent).toEqual(expectedEvent); + }); + + it('ignore view_names if undefined', () => { + const routing = new RoutingInstrumentation(); + const integration = new ReactNativeTracing({ + routingInstrumentation: routing, + }); + + const event: Event = { contexts: { app: { key: 'value ' } } }; + const expectedEvent: Event = { contexts: { app: { key: 'value ' } } }; + + const processedEvent = integration['_getCurrentViewEventProcessor'](event); + + expect(processedEvent).toEqual(expectedEvent); + }); + }); }); }); describe('Handling deprecated options', () => {