diff --git a/CHANGELOG.md b/CHANGELOG.md index 0205c0f865..b0cc8c8e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Add expo constants on event context ([#5748](https://github.com/getsentry/sentry-react-native/pull/5748)) - Capture dynamic route params as span attributes for Expo Router navigations ([#5750](https://github.com/getsentry/sentry-react-native/pull/5750)) ### Fixes diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index 15123d32a6..a3effed7c0 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -17,6 +17,7 @@ import { dedupeIntegration, deviceContextIntegration, eventOriginIntegration, + expoConstantsIntegration, expoContextIntegration, functionToStringIntegration, hermesProfilingIntegration, @@ -131,6 +132,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ } integrations.push(expoContextIntegration()); + integrations.push(expoConstantsIntegration()); if (options.spotlight && __DEV__) { const sidecarUrl = typeof options.spotlight === 'string' ? options.spotlight : undefined; diff --git a/packages/core/src/js/integrations/expoconstants.ts b/packages/core/src/js/integrations/expoconstants.ts new file mode 100644 index 0000000000..ee8518e643 --- /dev/null +++ b/packages/core/src/js/integrations/expoconstants.ts @@ -0,0 +1,128 @@ +import type { Event, Integration } from '@sentry/core'; +import { isExpo } from '../utils/environment'; +import type { ExpoConstants } from '../utils/expoglobalobject'; +import { getExpoConstants } from '../utils/expomodules'; + +const INTEGRATION_NAME = 'ExpoConstants'; + +export const EXPO_CONSTANTS_CONTEXT_KEY = 'expo_constants'; + +/** Load Expo Constants as event context. */ +export const expoConstantsIntegration = (): Integration => { + let _expoConstantsContextCached: ExpoConstantsContext | undefined; + + function processEvent(event: Event): Event { + if (!isExpo()) { + return event; + } + + event.contexts = event.contexts || {}; + event.contexts[EXPO_CONSTANTS_CONTEXT_KEY] = { + ...getExpoConstantsContextCached(), + }; + + return event; + } + + function getExpoConstantsContextCached(): ExpoConstantsContext { + if (_expoConstantsContextCached) { + return _expoConstantsContextCached; + } + + return (_expoConstantsContextCached = getExpoConstantsContext()); + } + + return { + name: INTEGRATION_NAME, + processEvent, + }; +}; + +/** + * @internal Exposed for testing purposes + */ +export function getExpoConstantsContext(): ExpoConstantsContext { + const expoConstants = getExpoConstants(); + if (!expoConstants) { + return {}; + } + + const context: ExpoConstantsContext = {}; + + addStringField(context, 'execution_environment', expoConstants.executionEnvironment); + addStringField(context, 'app_ownership', expoConstants.appOwnership); + addBooleanField(context, 'debug_mode', expoConstants.debugMode); + addStringField(context, 'expo_version', expoConstants.expoVersion); + addStringField(context, 'expo_runtime_version', expoConstants.expoRuntimeVersion); + addStringField(context, 'session_id', expoConstants.sessionId); + addNumberField(context, 'status_bar_height', expoConstants.statusBarHeight); + + addExpoConfigFields(context, expoConstants); + addEasConfigFields(context, expoConstants); + + return context; +} + +function addStringField( + context: ExpoConstantsContext, + key: keyof ExpoConstantsContext, + value: string | null | undefined, +): void { + if (typeof value === 'string' && value) { + (context as Record)[key] = value; + } +} + +function addBooleanField( + context: ExpoConstantsContext, + key: keyof ExpoConstantsContext, + value: boolean | undefined, +): void { + if (typeof value === 'boolean') { + (context as Record)[key] = value; + } +} + +function addNumberField( + context: ExpoConstantsContext, + key: keyof ExpoConstantsContext, + value: number | undefined, +): void { + if (typeof value === 'number') { + (context as Record)[key] = value; + } +} + +function addExpoConfigFields(context: ExpoConstantsContext, expoConstants: ExpoConstants): void { + if (!expoConstants.expoConfig) { + return; + } + + addStringField(context, 'app_name', expoConstants.expoConfig.name); + addStringField(context, 'app_slug', expoConstants.expoConfig.slug); + addStringField(context, 'app_version', expoConstants.expoConfig.version); + addStringField(context, 'expo_sdk_version', expoConstants.expoConfig.sdkVersion); +} + +function addEasConfigFields(context: ExpoConstantsContext, expoConstants: ExpoConstants): void { + if (!expoConstants.easConfig) { + return; + } + + addStringField(context, 'eas_project_id', expoConstants.easConfig.projectId); +} + +type ExpoConstantsContext = Partial<{ + execution_environment: string; + app_ownership: string; + debug_mode: boolean; + expo_version: string; + expo_runtime_version: string; + session_id: string; + status_bar_height: number; + app_name: string; + app_slug: string; + app_version: string; + expo_sdk_version?: string; + eas_project_id: string; +}>; diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index c33323eba4..bc228de280 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -11,6 +11,7 @@ export { hermesProfilingIntegration } from '../profiling/integration'; export { screenshotIntegration } from './screenshot'; export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; +export { expoConstantsIntegration } from './expoconstants'; export { spotlightIntegration } from './spotlight'; export { mobileReplayIntegration } from '../replay/mobilereplay'; export { feedbackIntegration } from '../feedback/integration'; diff --git a/packages/core/src/js/utils/expoglobalobject.ts b/packages/core/src/js/utils/expoglobalobject.ts index e9bef4b2da..4a4a41a229 100644 --- a/packages/core/src/js/utils/expoglobalobject.ts +++ b/packages/core/src/js/utils/expoglobalobject.ts @@ -5,15 +5,9 @@ * * https://github.com/expo/expo/blob/b51b5139f2caa2a9495e4132437d7ca612276158/packages/expo-constants/src/Constants.ts * https://github.com/expo/expo/blob/b51b5139f2caa2a9495e4132437d7ca612276158/packages/expo-manifests/src/Manifests.ts + * https://github.com/expo/expo/blob/fce7f6eb2ea2611cb30e9cb20baaeee2ac0a18b6/packages/expo-constants/src/Constants.types.ts */ export interface ExpoConstants { - /** - * Deprecated. But until removed we can use it as user ID to match the native SDKs. - */ - installationId?: string; - /** - * Version of the Expo Go app - */ expoVersion?: string | null; manifest?: null | { [key: string]: unknown; @@ -23,6 +17,52 @@ export interface ExpoConstants { */ runtimeVersion?: string; }; + /** + * Returns the current execution environment. + * Values: 'bare', 'standalone', 'storeClient' + */ + executionEnvironment?: string; + /** + * Deprecated. Returns 'expo' when running in Expo Go, otherwise null. + */ + appOwnership?: string | null; + /** + * Identifies debug vs. production builds. + */ + debugMode?: boolean; + /** + * Unique identifier per app session. + */ + sessionId?: string; + /** + * Runtime version info. + */ + expoRuntimeVersion?: string | null; + /** + * Device status bar height. + */ + statusBarHeight?: number; + /** + * Available system fonts. + */ + systemFonts?: string[]; + /** + * The standard Expo config object defined in app.json and app.config.js files. + */ + expoConfig?: null | { + [key: string]: unknown; + name?: string; + slug?: string; + version?: string; + sdkVersion?: string; + }; + /** + * EAS configuration when applicable. + */ + easConfig?: null | { + [key: string]: unknown; + projectId?: string; + }; } /** diff --git a/packages/core/test/integrations/expoconstants.test.ts b/packages/core/test/integrations/expoconstants.test.ts new file mode 100644 index 0000000000..ed4604d159 --- /dev/null +++ b/packages/core/test/integrations/expoconstants.test.ts @@ -0,0 +1,201 @@ +import type { Client, Event } from '@sentry/core'; +import { + EXPO_CONSTANTS_CONTEXT_KEY, + expoConstantsIntegration, + getExpoConstantsContext, +} from '../../src/js/integrations/expoconstants'; +import * as environment from '../../src/js/utils/environment'; +import type { ExpoConstants } from '../../src/js/utils/expoglobalobject'; +import * as expoModules from '../../src/js/utils/expomodules'; + +jest.mock('../../src/js/utils/expomodules'); + +describe('Expo Constants Integration', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Non Expo App', () => { + beforeEach(() => { + jest.spyOn(environment, 'isExpo').mockReturnValue(false); + }); + + it('does not add expo constants context', () => { + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[EXPO_CONSTANTS_CONTEXT_KEY]).toBeUndefined(); + }); + }); + + describe('In Expo App', () => { + beforeEach(() => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + }); + + it('only calls getExpoConstants once', () => { + const getExpoConstantsMock = jest.spyOn(expoModules, 'getExpoConstants'); + + const integration = expoConstantsIntegration(); + integration.processEvent!({}, {}, {} as Client); + integration.processEvent!({}, {}, {} as Client); + + expect(getExpoConstantsMock).toHaveBeenCalledTimes(1); + }); + + it('added context does not share the same reference', async () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue({}); + + const integration = expoConstantsIntegration(); + const event1 = await integration.processEvent!({}, {}, {} as Client); + const event2 = await integration.processEvent!({}, {}, {} as Client); + + expect(event1.contexts![EXPO_CONSTANTS_CONTEXT_KEY]).not.toBe(event2.contexts![EXPO_CONSTANTS_CONTEXT_KEY]); + }); + + it('adds empty context if ExpoConstants module is missing', () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue(undefined); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[EXPO_CONSTANTS_CONTEXT_KEY]).toStrictEqual({}); + }); + + it('adds empty context if ExpoConstants module is empty', () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue({}); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[EXPO_CONSTANTS_CONTEXT_KEY]).toStrictEqual({}); + }); + + it('adds all constants', () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue({ + executionEnvironment: 'standalone', + appOwnership: 'expo', + debugMode: true, + expoVersion: '51.0.0', + expoRuntimeVersion: '1.0.0', + sessionId: 'test-session-id', + statusBarHeight: 44, + expoConfig: { + name: 'TestApp', + slug: 'test-app', + version: '1.0.0', + sdkVersion: '51.0.0', + }, + easConfig: { + projectId: 'test-project-id', + }, + }); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[EXPO_CONSTANTS_CONTEXT_KEY]).toEqual({ + execution_environment: 'standalone', + app_ownership: 'expo', + debug_mode: true, + expo_version: '51.0.0', + expo_runtime_version: '1.0.0', + session_id: 'test-session-id', + status_bar_height: 44, + app_name: 'TestApp', + app_slug: 'test-app', + app_version: '1.0.0', + expo_sdk_version: '51.0.0', + eas_project_id: 'test-project-id', + }); + }); + + it('adds partial constants when only some are available', () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue({ + executionEnvironment: 'bare', + debugMode: false, + }); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[EXPO_CONSTANTS_CONTEXT_KEY]).toEqual({ + execution_environment: 'bare', + debug_mode: false, + }); + }); + + it('avoids adding values of unexpected types', () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue({ + executionEnvironment: 123, + appOwnership: {}, + debugMode: 'true', + expoVersion: {}, + sessionId: {}, + statusBarHeight: 'not a number', + } as unknown as ExpoConstants); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[EXPO_CONSTANTS_CONTEXT_KEY]).toStrictEqual({}); + }); + + it('avoids adding empty string values', () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue({ + executionEnvironment: '', + appOwnership: '', + expoVersion: '', + expoRuntimeVersion: '', + sessionId: '', + expoConfig: { + name: '', + slug: '', + version: '', + sdkVersion: '', + }, + easConfig: { + projectId: '', + }, + }); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[EXPO_CONSTANTS_CONTEXT_KEY]).toStrictEqual({}); + }); + + it('handles null config values', () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue({ + executionEnvironment: 'standalone', + expoConfig: null, + easConfig: null, + }); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[EXPO_CONSTANTS_CONTEXT_KEY]).toEqual({ + execution_environment: 'standalone', + }); + }); + }); + + describe('getExpoConstantsContext', () => { + it('returns empty object when constants module is missing', () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue(undefined); + + const context = getExpoConstantsContext(); + + expect(context).toStrictEqual({}); + }); + + it('does not add null values', () => { + jest.spyOn(expoModules, 'getExpoConstants').mockReturnValue({ + appOwnership: null, + expoVersion: null, + expoRuntimeVersion: null, + }); + + const context = getExpoConstantsContext(); + + expect(context).toStrictEqual({}); + }); + }); + + function executeIntegrationFor(mockedEvent: Event): Event { + return expoConstantsIntegration().processEvent!(mockedEvent, {}, {} as Client) as Event; + } +}); diff --git a/samples/expo/app.json b/samples/expo/app.json index e003f04a1d..a4255d2c12 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -14,9 +14,7 @@ "resizeMode": "contain", "backgroundColor": "#ffffff" }, - "assetBundlePatterns": [ - "**/*" - ], + "assetBundlePatterns": ["**/*"], "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", @@ -91,4 +89,4 @@ "url": "https://u.expo.dev/00000000-0000-0000-0000-000000000000" } } -} \ No newline at end of file +}