From 6e6ea746c845a8b5247d1c27664ebd4f7d0fbf70 Mon Sep 17 00:00:00 2001 From: lucas Date: Wed, 30 Jul 2025 18:55:21 +0100 Subject: [PATCH 1/7] add javascript logic (WIP when to spawn cache) / implemented Android context info --- .../io/sentry/react/RNSentryModuleImpl.java | 45 +++++++++++++ .../java/io/sentry/react/RNSentryModule.java | 5 ++ packages/core/src/js/NativeRNSentry.ts | 1 + packages/core/src/js/integrations/default.ts | 4 ++ packages/core/src/js/integrations/exports.ts | 1 + .../js/integrations/logEnricherIntegration.ts | 66 +++++++++++++++++++ packages/core/src/js/wrapper.ts | 14 ++++ 7 files changed, 136 insertions(+) create mode 100644 packages/core/src/js/integrations/logEnricherIntegration.ts 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..2ffedef7ad 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,44 @@ 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 contextsMap)) { + promise.resolve(null); + return; + } + + 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/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..a66836e0a9 --- /dev/null +++ b/packages/core/src/js/integrations/logEnricherIntegration.ts @@ -0,0 +1,66 @@ +/* eslint-disable complexity */ +import type { Integration, Log } from '@sentry/core'; +import { logger } from '@sentry/core'; +import {} from 'react-native'; +import {} from '../breadcrumb'; +import { NATIVE } from '../wrapper'; + +const INTEGRATION_NAME = 'LogEnricher'; + +export const logEnricherIntegration = (): Integration => { + return { + name: INTEGRATION_NAME, + setup(client) { + setTimeout(() => { + cacheLogContext().then(() => { + client.on('beforeCaptureLog', log => { + processLog(log); + }) + }, + reason => { + logger.log(reason); + }, + ); + }, 1000); + }, + }; +}; + +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, + }; + NativeCache = response?.contexts?.os && { + ...NativeCache, + os: response.contexts.os, + version: response.contexts.version, + }; + NativeCache = response?.contexts?.release && { + ...NativeCache, + 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.name && (log.attributes['os.name'] = NativeCache.name); + 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 */ From 95b280a673df9d5df1ba0d1f0c78709fb1ba6030 Mon Sep 17 00:00:00 2001 From: lucas Date: Thu, 31 Jul 2025 17:35:03 +0100 Subject: [PATCH 2/7] fix logic and also implement ios side --- packages/core/ios/RNSentry.mm | 53 +++++++++++++++++++ .../js/integrations/logEnricherIntegration.ts | 35 ++++++------ 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 00c8eb0a5b..001383866b 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -523,6 +523,59 @@ - (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/integrations/logEnricherIntegration.ts b/packages/core/src/js/integrations/logEnricherIntegration.ts index a66836e0a9..5ceb13edf6 100644 --- a/packages/core/src/js/integrations/logEnricherIntegration.ts +++ b/packages/core/src/js/integrations/logEnricherIntegration.ts @@ -1,8 +1,8 @@ /* eslint-disable complexity */ import type { Integration, Log } from '@sentry/core'; import { logger } from '@sentry/core'; -import {} from 'react-native'; -import {} from '../breadcrumb'; +import { } from 'react-native'; +import { } from '../breadcrumb'; import { NATIVE } from '../wrapper'; const INTEGRATION_NAME = 'LogEnricher'; @@ -31,20 +31,23 @@ 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, - }; - NativeCache = response?.contexts?.os && { - ...NativeCache, - os: response.contexts.os, - version: response.contexts.version, - }; - NativeCache = response?.contexts?.release && { - ...NativeCache, - release: response.contexts.release, + + 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}`); } @@ -60,7 +63,7 @@ function processLog(log: Log): void { 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.name && (log.attributes['os.name'] = NativeCache.name); + NativeCache.os && (log.attributes['os.name'] = NativeCache.os); NativeCache.version && (log.attributes['os.version'] = NativeCache.version); NativeCache.release && (log.attributes['sentry.release'] = NativeCache.release); } From d0bd30cc7cd3999cac33718921400c5c2adad235 Mon Sep 17 00:00:00 2001 From: lucas Date: Mon, 4 Aug 2025 22:30:42 +0100 Subject: [PATCH 3/7] remove setTimeout and use afterInit hook, add tests --- .../js/integrations/logEnricherIntegration.ts | 26 +- .../logEnricherIntegration.test.ts | 410 ++++++++++++++++++ 2 files changed, 422 insertions(+), 14 deletions(-) create mode 100644 packages/core/test/integrations/logEnricherIntegration.test.ts diff --git a/packages/core/src/js/integrations/logEnricherIntegration.ts b/packages/core/src/js/integrations/logEnricherIntegration.ts index 5ceb13edf6..67b871557d 100644 --- a/packages/core/src/js/integrations/logEnricherIntegration.ts +++ b/packages/core/src/js/integrations/logEnricherIntegration.ts @@ -1,8 +1,7 @@ /* eslint-disable complexity */ import type { Integration, Log } from '@sentry/core'; import { logger } from '@sentry/core'; -import { } from 'react-native'; -import { } from '../breadcrumb'; +import type { ReactNativeClient } from '../client'; import { NATIVE } from '../wrapper'; const INTEGRATION_NAME = 'LogEnricher'; @@ -10,18 +9,19 @@ const INTEGRATION_NAME = 'LogEnricher'; export const logEnricherIntegration = (): Integration => { return { name: INTEGRATION_NAME, - setup(client) { - setTimeout(() => { - cacheLogContext().then(() => { - client.on('beforeCaptureLog', log => { - processLog(log); - }) - }, + setup(client: ReactNativeClient) { + client.on('afterInit', () => { + cacheLogContext().then( + () => { + client.on('beforeCaptureLog', (log: Log) => { + processLog(log); + }); + }, reason => { logger.log(reason); }, ); - }, 1000); + }); }, }; }; @@ -32,8 +32,7 @@ async function cacheLogContext(): Promise { try { const response = await NATIVE.fetchNativeLogAttributes(); - NativeCache = - { + NativeCache = { ...(response?.contexts?.device && { brand: response.contexts.device?.brand, model: response.contexts.device?.model, @@ -45,9 +44,8 @@ async function cacheLogContext(): Promise { }), ...(response?.contexts?.release && { release: response.contexts.release, - }) + }), }; - } catch (e) { return Promise.reject(`[LOGS]: Failed to prepare attributes from Native Layer: ${e}`); } diff --git a/packages/core/test/integrations/logEnricherIntegration.test.ts b/packages/core/test/integrations/logEnricherIntegration.test.ts new file mode 100644 index 0000000000..2f5b1cdb1f --- /dev/null +++ b/packages/core/test/integrations/logEnricherIntegration.test.ts @@ -0,0 +1,410 @@ +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( + // @ts-ignore + ([eventName, _]) => eventName === '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)); + }); + }); +}); From 44efeb102759b53558f78ac64597ee32fca642f5 Mon Sep 17 00:00:00 2001 From: lucas Date: Mon, 4 Aug 2025 22:32:18 +0100 Subject: [PATCH 4/7] yarn fix --- .../test/integrations/logEnricherIntegration.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/core/test/integrations/logEnricherIntegration.test.ts b/packages/core/test/integrations/logEnricherIntegration.test.ts index 2f5b1cdb1f..fc75ecaa3b 100644 --- a/packages/core/test/integrations/logEnricherIntegration.test.ts +++ b/packages/core/test/integrations/logEnricherIntegration.test.ts @@ -15,10 +15,9 @@ jest.mock('@sentry/core', () => ({ const mockLogger = logger as jest.Mocked; -function on_beforeCaptureLogCount(client: jest.Mocked){ +function on_beforeCaptureLogCount(client: jest.Mocked) { const beforeCaptureLogCalls = client.on.mock.calls.filter( - // @ts-ignore - ([eventName, _]) => eventName === 'beforeCaptureLog' + ([eventName, _]) => eventName.toString() === 'beforeCaptureLog', ); return beforeCaptureLogCalls.length; @@ -254,7 +253,6 @@ describe('LogEnricher Integration', () => { }); it('should not register beforeCaptureLog handler when native fetch fails', async () => { - const integration = logEnricherIntegration(); mockFetchNativeLogAttributes.mockRejectedValue(new Error('Failed')); @@ -266,7 +264,7 @@ describe('LogEnricher Integration', () => { await jest.runAllTimersAsync(); expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer') + expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer'), ); // Default client count. @@ -297,7 +295,6 @@ describe('LogEnricher Integration', () => { expect(mockLog.attributes).toEqual({}); expect(on_beforeCaptureLogCount(mockClient)).toBe(2); - }); it('should handle partial device context', async () => { @@ -382,7 +379,7 @@ describe('LogEnricher Integration', () => { await jest.runAllTimersAsync(); expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer') + expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer'), ); expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('Failed to Initialize')); From ea2def5cfa3c782c2f8e1dcf3ae6a4aaec512b5b Mon Sep 17 00:00:00 2001 From: lucas Date: Mon, 4 Aug 2025 22:38:44 +0100 Subject: [PATCH 5/7] use java 8 syntax --- .../src/main/java/io/sentry/react/RNSentryModuleImpl.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 2ffedef7ad..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 @@ -1047,11 +1047,14 @@ protected void fetchNativeLogContexts( InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) .get("contexts"); - if (!(contextsObj instanceof Map contextsMap)) { + 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")); From 7eed3c207a8a5cead71e87e791ad648963cb68b7 Mon Sep 17 00:00:00 2001 From: lucas Date: Mon, 4 Aug 2025 22:39:55 +0100 Subject: [PATCH 6/7] changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fea37239ef..d520915167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. + +## Unreleased + +## Features + +- Logs now contains more attributes like release, os and device information ([#5032](https://github.com/getsentry/sentry-react-native/pull/5032)) + ## 7.0.0-rc.1 ### Various fixes & improvements From cc6171c3e6787f0cfd2e8e36698135f695c98785 Mon Sep 17 00:00:00 2001 From: lucas Date: Mon, 4 Aug 2025 22:47:58 +0100 Subject: [PATCH 7/7] clang fix? --- packages/core/ios/RNSentry.mm | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 001383866b..ed5cb4b459 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -523,7 +523,6 @@ - (NSDictionary *)fetchNativeStackFramesBy:(NSArray *)instructionsAd return [self fetchNativeStackFramesBy:instructionsAddr symbolicate:dladdr]; } - RCT_EXPORT_METHOD(fetchNativeLogAttributes : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) @@ -531,7 +530,6 @@ - (NSDictionary *)fetchNativeStackFramesBy:(NSArray *)instructionsAd __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 @@ -558,14 +556,16 @@ - (NSDictionary *)fetchNativeStackFramesBy:(NSArray *)instructionsAd if (extraContext) { NSDictionary *extraDevice = extraContext[@"device"]; if ([extraDevice isKindOfClass:[NSDictionary class]]) { - NSMutableDictionary *mergedDevice = [contexts[@"device"] mutableCopy] ?: [NSMutableDictionary new]; + 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]; + NSMutableDictionary *mergedOS = + [contexts[@"os"] mutableCopy] ?: [NSMutableDictionary new]; [mergedOS addEntriesFromDictionary:extraOS]; contexts[@"os"] = mergedOS; } @@ -573,7 +573,6 @@ - (NSDictionary *)fetchNativeStackFramesBy:(NSArray *)instructionsAd result[@"contexts"] = contexts; }]; resolve(result); - } RCT_EXPORT_METHOD(fetchNativeDeviceContexts