diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c72d0fd6..6463aa3a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +## Features + +- Logs now contains more attributes like release, os and device information ([#5032](https://github.com/getsentry/sentry-react-native/pull/5032)) + ### Dependencies - Bump Android SDK from v8.17.0 to v8.18.0 ([#5034](https://github.com/getsentry/sentry-react-native/pull/5034)) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 12ab66bd80..c1da1b9f07 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -988,6 +988,13 @@ private String readStringFromFile(File path) throws IOException { } } + public void fetchNativeLogAttributes(Promise promise) { + final @NotNull SentryOptions options = ScopesAdapter.getInstance().getOptions(); + final @Nullable Context context = this.getReactApplicationContext().getApplicationContext(); + final @Nullable IScope currentScope = InternalSentrySdk.getCurrentScope(); + fetchNativeLogContexts(promise, options, context, currentScope); + } + public void fetchNativeDeviceContexts(Promise promise) { final @NotNull SentryOptions options = ScopesAdapter.getInstance().getOptions(); final @Nullable Context context = this.getReactApplicationContext().getApplicationContext(); @@ -1025,6 +1032,47 @@ protected void fetchNativeDeviceContexts( promise.resolve(deviceContext); } + // Basically fetchNativeDeviceContexts but filtered to only get contexts info. + protected void fetchNativeLogContexts( + Promise promise, + final @NotNull SentryOptions options, + final @Nullable Context osContext, + final @Nullable IScope currentScope) { + if (!(options instanceof SentryAndroidOptions) || osContext == null) { + promise.resolve(null); + return; + } + + Object contextsObj = + InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) + .get("contexts"); + + if (!(contextsObj instanceof Map)) { + promise.resolve(null); + return; + } + + @SuppressWarnings("unchecked") + Map contextsMap = (Map) contextsObj; + + Map contextItems = new HashMap<>(); + if (contextsMap.containsKey("os")) { + contextItems.put("os", contextsMap.get("os")); + } + + if (contextsMap.containsKey("device")) { + contextItems.put("device", contextsMap.get("device")); + } + + contextItems.put("release", options.getRelease()); + + Map logContext = new HashMap<>(); + logContext.put("contexts", contextItems); + Object filteredContext = RNSentryMapConverter.convertToWritable(logContext); + + promise.resolve(filteredContext); + } + public void fetchNativeSdkInfo(Promise promise) { final @Nullable SdkVersion sdkVersion = ScopesAdapter.getInstance().getOptions().getSdkVersion(); diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 5b14f05c92..993969d830 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -127,6 +127,11 @@ public void disableNativeFramesTracking() { this.impl.disableNativeFramesTracking(); } + @Override + public void fetchNativeLogAttributes(Promise promise) { + this.impl.fetchNativeLogAttributes(promise); + } + @Override public void fetchNativeDeviceContexts(Promise promise) { this.impl.fetchNativeDeviceContexts(promise); diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 00c8eb0a5b..ed5cb4b459 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -523,6 +523,58 @@ - (NSDictionary *)fetchNativeStackFramesBy:(NSArray *)instructionsAd return [self fetchNativeStackFramesBy:instructionsAddr symbolicate:dladdr]; } +RCT_EXPORT_METHOD(fetchNativeLogAttributes + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) +{ + __block NSMutableDictionary *result = [NSMutableDictionary new]; + + [SentrySDKWrapper configureScope:^(SentryScope *_Nonnull scope) { + // Serialize to get contexts dictionary + NSDictionary *serializedScope = [scope serialize]; + NSDictionary *allContexts = serializedScope[@"context"]; // It's singular here, annoyingly + + NSMutableDictionary *contexts = [NSMutableDictionary new]; + + NSDictionary *device = allContexts[@"device"]; + if ([device isKindOfClass:[NSDictionary class]]) { + contexts[@"device"] = device; + } + + NSDictionary *os = allContexts[@"os"]; + if ([os isKindOfClass:[NSDictionary class]]) { + contexts[@"os"] = os; + } + + NSString *releaseName = [SentrySDK options].releaseName; + if (releaseName) { + contexts[@"release"] = releaseName; + } + // Merge extra context + NSDictionary *extraContext = [PrivateSentrySDKOnly getExtraContext]; + + if (extraContext) { + NSDictionary *extraDevice = extraContext[@"device"]; + if ([extraDevice isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *mergedDevice = + [contexts[@"device"] mutableCopy] ?: [NSMutableDictionary new]; + [mergedDevice addEntriesFromDictionary:extraDevice]; + contexts[@"device"] = mergedDevice; + } + + NSDictionary *extraOS = extraContext[@"os"]; + if ([extraOS isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *mergedOS = + [contexts[@"os"] mutableCopy] ?: [NSMutableDictionary new]; + [mergedOS addEntriesFromDictionary:extraOS]; + contexts[@"os"] = mergedOS; + } + } + result[@"contexts"] = contexts; + }]; + resolve(result); +} + RCT_EXPORT_METHOD(fetchNativeDeviceContexts : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 07b47f98ed..cdfbb0d781 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -24,6 +24,7 @@ export interface Spec extends TurboModule { fetchNativeRelease(): Promise; fetchNativeSdkInfo(): Promise; fetchNativeDeviceContexts(): Promise; + fetchNativeLogAttributes(): Promise; fetchNativeAppStart(): Promise; fetchNativeFrames(): Promise; initNativeSdk(options: UnsafeObject): Promise; diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index 2967440e0b..2cc6fafd30 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -23,6 +23,7 @@ import { httpClientIntegration, httpContextIntegration, inboundFiltersIntegration, + logEnricherIntegration, mobileReplayIntegration, modulesLoaderIntegration, nativeLinkedErrorsIntegration, @@ -84,6 +85,9 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ if (options.enableNative) { integrations.push(deviceContextIntegration()); integrations.push(modulesLoaderIntegration()); + if (options._experiments?.enableLogs) { + integrations.push(logEnricherIntegration()); + } if (options.attachScreenshot) { integrations.push(screenshotIntegration()); } diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index 6a7ad0feef..4f4d0fb0ac 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -24,6 +24,7 @@ export { appRegistryIntegration } from './appRegistry'; export { timeToDisplayIntegration } from '../tracing/integrations/timeToDisplayIntegration'; export { breadcrumbsIntegration } from './breadcrumbs'; export { primitiveTagIntegration } from './primitiveTagIntegration'; +export { logEnricherIntegration } from './logEnricherIntegration'; export { browserApiErrorsIntegration, diff --git a/packages/core/src/js/integrations/logEnricherIntegration.ts b/packages/core/src/js/integrations/logEnricherIntegration.ts new file mode 100644 index 0000000000..67b871557d --- /dev/null +++ b/packages/core/src/js/integrations/logEnricherIntegration.ts @@ -0,0 +1,67 @@ +/* eslint-disable complexity */ +import type { Integration, Log } from '@sentry/core'; +import { logger } from '@sentry/core'; +import type { ReactNativeClient } from '../client'; +import { NATIVE } from '../wrapper'; + +const INTEGRATION_NAME = 'LogEnricher'; + +export const logEnricherIntegration = (): Integration => { + return { + name: INTEGRATION_NAME, + setup(client: ReactNativeClient) { + client.on('afterInit', () => { + cacheLogContext().then( + () => { + client.on('beforeCaptureLog', (log: Log) => { + processLog(log); + }); + }, + reason => { + logger.log(reason); + }, + ); + }); + }, + }; +}; + +let NativeCache: Record | undefined = undefined; + +async function cacheLogContext(): Promise { + try { + const response = await NATIVE.fetchNativeLogAttributes(); + + NativeCache = { + ...(response?.contexts?.device && { + brand: response.contexts.device?.brand, + model: response.contexts.device?.model, + family: response.contexts.device?.family, + }), + ...(response?.contexts?.os && { + os: response.contexts.os.name, + version: response.contexts.os.version, + }), + ...(response?.contexts?.release && { + release: response.contexts.release, + }), + }; + } catch (e) { + return Promise.reject(`[LOGS]: Failed to prepare attributes from Native Layer: ${e}`); + } + return Promise.resolve(); +} + +function processLog(log: Log): 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); +} diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 77c8bdeb2c..955c5245d7 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -84,6 +84,7 @@ interface SentryNativeWrapper { fetchNativeRelease(): PromiseLike; fetchNativeDeviceContexts(): PromiseLike; + fetchNativeLogAttributes(): Promise; fetchNativeAppStart(): PromiseLike; fetchNativeFrames(): PromiseLike; fetchNativeSdkInfo(): PromiseLike; @@ -282,6 +283,19 @@ export const NATIVE: SentryNativeWrapper = { return nativeIsReady; }, + /** + * Fetches the attributes to be set into logs from Native + */ + async fetchNativeLogAttributes(): Promise { + if (!this.enableNative) { + throw this._DisabledNativeError; + } + if (!this._isModuleLoaded(RNSentry)) { + throw this._NativeClientError; + } + + return RNSentry.fetchNativeLogAttributes(); + }, /** * Fetches the release from native */ diff --git a/packages/core/test/integrations/logEnricherIntegration.test.ts b/packages/core/test/integrations/logEnricherIntegration.test.ts new file mode 100644 index 0000000000..fc75ecaa3b --- /dev/null +++ b/packages/core/test/integrations/logEnricherIntegration.test.ts @@ -0,0 +1,407 @@ +import type { Client, Log } from '@sentry/core'; +import { logger } from '@sentry/core'; +import { logEnricherIntegration } from '../../src/js/integrations/logEnricherIntegration'; +import type { NativeDeviceContextsResponse } from '../../src/js/NativeRNSentry'; +import { NATIVE } from '../../src/js/wrapper'; + +// Mock the NATIVE wrapper +jest.mock('../../src/js/wrapper'); +jest.mock('@sentry/core', () => ({ + ...jest.requireActual('@sentry/core'), + logger: { + log: jest.fn(), + }, +})); + +const mockLogger = logger as jest.Mocked; + +function on_beforeCaptureLogCount(client: jest.Mocked) { + const beforeCaptureLogCalls = client.on.mock.calls.filter( + ([eventName, _]) => eventName.toString() === 'beforeCaptureLog', + ); + + return beforeCaptureLogCalls.length; +} + +describe('LogEnricher Integration', () => { + let mockClient: jest.Mocked; + let mockOn: jest.Mock; + let mockFetchNativeLogAttributes: jest.Mock; + + const triggerAfterInit = () => { + const afterInitCallback = mockOn.mock.calls.find(call => call[0] === 'afterInit')?.[1] as (() => void) | undefined; + expect(afterInitCallback).toBeDefined(); + afterInitCallback!(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockOn = jest.fn(); + mockFetchNativeLogAttributes = jest.fn(); + + mockClient = { + on: mockOn, + } as unknown as jest.Mocked; + + (NATIVE as jest.Mocked).fetchNativeLogAttributes = mockFetchNativeLogAttributes; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('setup', () => { + it('should set up the integration and register beforeCaptureLog handler after afterInit event', async () => { + const integration = logEnricherIntegration(); + + // Mock successful native response + 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); + + integration.setup(mockClient); + + // Initially, only afterInit handler should be registered + expect(mockOn).toHaveBeenCalledWith('afterInit', expect.any(Function)); + expect(mockOn).toHaveBeenCalledTimes(1); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockOn).toHaveBeenCalledWith('beforeCaptureLog', expect.any(Function)); + expect(mockFetchNativeLogAttributes).toHaveBeenCalledTimes(1); + }); + + it('should handle native fetch failure gracefully', async () => { + const integration = logEnricherIntegration(); + + const errorMessage = 'Native fetch failed'; + mockFetchNativeLogAttributes.mockRejectedValue(new Error(errorMessage)); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer'), + ); + expect(mockOn).toHaveBeenCalledTimes(1); + }); + + it('should handle null response from native layer', async () => { + const integration = logEnricherIntegration(); + + mockFetchNativeLogAttributes.mockResolvedValue(null); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockOn).toHaveBeenCalledWith('beforeCaptureLog', expect.any(Function)); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(1); + }); + }); + + describe('log processing', () => { + let logHandler: (log: Log) => void; + let mockLog: Log; + + 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); + + integration.setup(mockClient); + + // Simulate the afterInit event + triggerAfterInit(); + + // Wait for the async operations to complete + await jest.runAllTimersAsync(); + + // Extract the log handler + 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 enrich log with device attributes', () => { + 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', + }); + }); + + it('should preserve existing log attributes', () => { + mockLog.attributes = { + existing: 'value', + 'custom.attr': 'custom-value', + }; + + logHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + existing: 'value', + 'custom.attr': 'custom-value', + 'device.brand': 'Apple', + 'device.model': 'iPhone 14', + 'device.family': 'iPhone', + 'os.name': 'iOS', + 'os.version': '16.0', + 'sentry.release': '1.0.0', + }); + }); + + it('should handle log without attributes', () => { + mockLog.attributes = 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', + }); + }); + + it('should only add attributes that exist in cache', async () => { + const integration = logEnricherIntegration(); + + const partialNativeResponse: NativeDeviceContextsResponse = { + contexts: { + device: { + brand: 'Apple', + // model and family missing + } as Record, + os: { + name: 'iOS', + // version missing + } as Record, + // release missing + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(partialNativeResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + const newLogHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + newLogHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'device.brand': 'Apple', + 'os.name': 'iOS', + }); + }); + + it('should not register beforeCaptureLog handler when native fetch fails', async () => { + const integration = logEnricherIntegration(); + + mockFetchNativeLogAttributes.mockRejectedValue(new Error('Failed')); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer'), + ); + + // Default client count. + expect(on_beforeCaptureLogCount(mockClient)).toBe(1); + }); + + it('should handle empty contexts in native response', async () => { + const integration = logEnricherIntegration(); + + const emptyNativeResponse: NativeDeviceContextsResponse = { + contexts: {}, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(emptyNativeResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + const emptyLogHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + emptyLogHandler(mockLog); + + expect(mockLog.attributes).toEqual({}); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(2); + }); + + it('should handle partial device context', async () => { + const integration = logEnricherIntegration(); + + const partialDeviceResponse: NativeDeviceContextsResponse = { + contexts: { + device: { + brand: 'Samsung', + model: 'Galaxy S21', + // family missing + } as Record, + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(partialDeviceResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + const partialLogHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + partialLogHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'device.brand': 'Samsung', + 'device.model': 'Galaxy S21', + }); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(2); + }); + + it('should handle partial OS context', async () => { + const integration = logEnricherIntegration(); + + const partialOsResponse: NativeDeviceContextsResponse = { + contexts: { + os: { + name: 'Android', + // version missing + } as Record, + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(partialOsResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + const partialLogHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + partialLogHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'os.name': 'Android', + }); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(2); + }); + }); + + describe('error handling', () => { + it('should handle errors', async () => { + const integration = logEnricherIntegration(); + + mockFetchNativeLogAttributes.mockRejectedValue(new Error('Failed to Initialize')); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer'), + ); + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('Failed to Initialize')); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(0); + }); + + it('should handle malformed native response', async () => { + const integration = logEnricherIntegration(); + + const malformedResponse = { + someUnexpectedKey: 'value', + }; + + mockFetchNativeLogAttributes.mockResolvedValue(malformedResponse as NativeDeviceContextsResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockOn).toHaveBeenCalledWith('beforeCaptureLog', expect.any(Function)); + }); + }); +});