diff --git a/CHANGELOG.md b/CHANGELOG.md index de700db6fe..34a553ca29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Add mobile replay attributes to logs ([#5165](https://github.com/getsentry/sentry-react-native/pull/5165)) + ## 7.1.0 ### Fixes diff --git a/packages/core/src/js/integrations/logEnricherIntegration.ts b/packages/core/src/js/integrations/logEnricherIntegration.ts index 7b6b9dd786..285365cc77 100644 --- a/packages/core/src/js/integrations/logEnricherIntegration.ts +++ b/packages/core/src/js/integrations/logEnricherIntegration.ts @@ -14,7 +14,7 @@ export const logEnricherIntegration = (): Integration => { cacheLogContext().then( () => { client.on('beforeCaptureLog', (log: Log) => { - processLog(log); + processLog(log, client); }); }, reason => { @@ -28,6 +28,25 @@ export const logEnricherIntegration = (): Integration => { let NativeCache: Record | undefined = undefined; +/** + * Sets a log attribute if the value exists and the attribute key is not already present. + * + * @param logAttributes - The log attributes object to modify. + * @param key - The attribute key to set. + * @param value - The value to set (only sets if truthy and key not present). + * @param setEvenIfPresent - Whether to set the attribute if it is present. Defaults to true. + */ +function setLogAttribute( + logAttributes: Record, + key: string, + value: unknown, + setEvenIfPresent = true, +): void { + if (value && (!logAttributes[key] || setEvenIfPresent)) { + logAttributes[key] = value; + } +} + async function cacheLogContext(): Promise { try { const response = await NATIVE.fetchNativeLogAttributes(); @@ -52,16 +71,25 @@ async function cacheLogContext(): Promise { return Promise.resolve(); } -function processLog(log: Log): void { +function processLog(log: Log, client: ReactNativeClient): void { if (NativeCache === undefined) { return; } - log.attributes = log.attributes ?? {}; - NativeCache.brand && (log.attributes['device.brand'] = NativeCache.brand); - NativeCache.model && (log.attributes['device.model'] = NativeCache.model); - NativeCache.family && (log.attributes['device.family'] = NativeCache.family); - NativeCache.os && (log.attributes['os.name'] = NativeCache.os); - NativeCache.version && (log.attributes['os.version'] = NativeCache.version); - NativeCache.release && (log.attributes['sentry.release'] = NativeCache.release); + // Save log.attributes to a new variable + const logAttributes = log.attributes ?? {}; + + // Use setLogAttribute with the variable instead of direct assignment + setLogAttribute(logAttributes, 'device.brand', NativeCache.brand); + setLogAttribute(logAttributes, 'device.model', NativeCache.model); + setLogAttribute(logAttributes, 'device.family', NativeCache.family); + setLogAttribute(logAttributes, 'os.name', NativeCache.os); + setLogAttribute(logAttributes, 'os.version', NativeCache.version); + setLogAttribute(logAttributes, 'sentry.release', NativeCache.release); + + const replay = client.getIntegrationByName string | null }>('MobileReplay'); + setLogAttribute(logAttributes, 'sentry.replay_id', replay?.getReplayId()); + + // Set log.attributes to the variable + log.attributes = logAttributes; } diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 3530303c47..e307b8b756 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -95,6 +95,7 @@ function mergeOptions(initOptions: Partial): Required; + getReplayId: () => string | null; }; /** @@ -173,6 +174,10 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau client.on('beforeAddBreadcrumb', enrichXhrBreadcrumbsForMobileReplay); } + function getReplayId(): string | null { + return NATIVE.getCurrentReplayId(); + } + // TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably // https://github.com/getsentry/sentry-javascript/blob/develop/packages/replay-internal/src/integration.ts#L45 return { @@ -180,6 +185,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau setup, processEvent, options: options, + getReplayId: getReplayId, }; }; @@ -187,5 +193,6 @@ const mobileReplayIntegrationNoop = (): MobileReplayIntegration => { return { name: MOBILE_REPLAY_INTEGRATION_NAME, options: defaultOptions, + getReplayId: () => null, // Mock implementation for noop version }; }; diff --git a/packages/core/test/integrations/logEnricherIntegration.test.ts b/packages/core/test/integrations/logEnricherIntegration.test.ts index c15e39c2ab..7834d19a78 100644 --- a/packages/core/test/integrations/logEnricherIntegration.test.ts +++ b/packages/core/test/integrations/logEnricherIntegration.test.ts @@ -27,6 +27,7 @@ describe('LogEnricher Integration', () => { let mockClient: jest.Mocked; let mockOn: jest.Mock; let mockFetchNativeLogAttributes: jest.Mock; + let mockGetIntegrationByName: jest.Mock; const triggerAfterInit = () => { const afterInitCallback = mockOn.mock.calls.find(call => call[0] === 'afterInit')?.[1] as (() => void) | undefined; @@ -40,9 +41,11 @@ describe('LogEnricher Integration', () => { mockOn = jest.fn(); mockFetchNativeLogAttributes = jest.fn(); + mockGetIntegrationByName = jest.fn(); mockClient = { on: mockOn, + getIntegrationByName: mockGetIntegrationByName, } as unknown as jest.Mocked; (NATIVE as jest.Mocked).fetchNativeLogAttributes = mockFetchNativeLogAttributes; @@ -404,4 +407,113 @@ describe('LogEnricher Integration', () => { expect(mockOn).toHaveBeenCalledWith('beforeCaptureLog', expect.any(Function)); }); }); + + describe('replay log functionality', () => { + let logHandler: (log: Log) => void; + let mockLog: Log; + let mockGetIntegrationByName: jest.Mock; + + beforeEach(async () => { + const integration = logEnricherIntegration(); + + const mockNativeResponse: NativeDeviceContextsResponse = { + contexts: { + device: { + brand: 'Apple', + model: 'iPhone 14', + family: 'iPhone', + } as Record, + os: { + name: 'iOS', + version: '16.0', + } as Record, + release: '1.0.0' as unknown as Record, + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(mockNativeResponse); + mockGetIntegrationByName = jest.fn(); + + mockClient = { + on: mockOn, + getIntegrationByName: mockGetIntegrationByName, + } as unknown as jest.Mocked; + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + logHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + mockLog = { + message: 'Test log message', + level: 'info', + attributes: {}, + }; + }); + + it('should add replay_id when MobileReplay integration is available and returns a replay ID', () => { + const mockReplayId = 'replay-123-abc'; + const mockReplayIntegration = { + getReplayId: jest.fn().mockReturnValue(mockReplayId), + }; + + mockGetIntegrationByName.mockReturnValue(mockReplayIntegration); + + logHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'device.brand': 'Apple', + 'device.model': 'iPhone 14', + 'device.family': 'iPhone', + 'os.name': 'iOS', + 'os.version': '16.0', + 'sentry.release': '1.0.0', + 'sentry.replay_id': mockReplayId, + }); + expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay'); + expect(mockReplayIntegration.getReplayId).toHaveBeenCalled(); + }); + + it('should not add replay_id when MobileReplay integration returns null', () => { + const mockReplayIntegration = { + getReplayId: jest.fn().mockReturnValue(null), + }; + + mockGetIntegrationByName.mockReturnValue(mockReplayIntegration); + + logHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'device.brand': 'Apple', + 'device.model': 'iPhone 14', + 'device.family': 'iPhone', + 'os.name': 'iOS', + 'os.version': '16.0', + 'sentry.release': '1.0.0', + }); + expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay'); + expect(mockReplayIntegration.getReplayId).toHaveBeenCalled(); + }); + + it('should not add replay_id when MobileReplay integration is not available', () => { + mockGetIntegrationByName.mockReturnValue(undefined); + + logHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'device.brand': 'Apple', + 'device.model': 'iPhone 14', + 'device.family': 'iPhone', + 'os.name': 'iOS', + 'os.version': '16.0', + 'sentry.release': '1.0.0', + }); + expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay'); + }); + }); });