From 93a5b5c03b8ea6dd0a418f057e4083d0b97e8103 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 26 Jan 2026 12:50:41 +0100 Subject: [PATCH 1/8] Support for applying scope attributes to logs --- .../js/integrations/logEnricherIntegration.ts | 54 +++- .../logEnricherIntegration.test.ts | 237 +++++++++++++++++- samples/expo/utils/setScopeProperties.ts | 12 + .../src/setScopeProperties.ts | 12 + .../react-native/src/setScopeProperties.ts | 10 + 5 files changed, 321 insertions(+), 4 deletions(-) diff --git a/packages/core/src/js/integrations/logEnricherIntegration.ts b/packages/core/src/js/integrations/logEnricherIntegration.ts index 285365cc77..ba8a6272fd 100644 --- a/packages/core/src/js/integrations/logEnricherIntegration.ts +++ b/packages/core/src/js/integrations/logEnricherIntegration.ts @@ -1,6 +1,6 @@ /* eslint-disable complexity */ import type { Integration, Log } from '@sentry/core'; -import { debug } from '@sentry/core'; +import { debug, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; import type { ReactNativeClient } from '../client'; import { NATIVE } from '../wrapper'; @@ -33,7 +33,7 @@ let NativeCache: Record | undefined = undefined; * * @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 value - The value to set (only sets if not null/undefined and key not present). * @param setEvenIfPresent - Whether to set the attribute if it is present. Defaults to true. */ function setLogAttribute( @@ -42,7 +42,7 @@ function setLogAttribute( value: unknown, setEvenIfPresent = true, ): void { - if (value && (!logAttributes[key] || setEvenIfPresent)) { + if (value != null && (!logAttributes[key] || setEvenIfPresent)) { logAttributes[key] = value; } } @@ -79,6 +79,13 @@ function processLog(log: Log, client: ReactNativeClient): void { // Save log.attributes to a new variable const logAttributes = log.attributes ?? {}; + // Apply scope attributes from all active scopes (global, isolation, and current) + // These are applied first so they can be overridden by more specific attributes + const scopeAttributes = collectScopeAttributes(); + Object.keys(scopeAttributes).forEach((key: string) => { + setLogAttribute(logAttributes, key, scopeAttributes[key], false); + }); + // Use setLogAttribute with the variable instead of direct assignment setLogAttribute(logAttributes, 'device.brand', NativeCache.brand); setLogAttribute(logAttributes, 'device.model', NativeCache.model); @@ -93,3 +100,44 @@ function processLog(log: Log, client: ReactNativeClient): void { // Set log.attributes to the variable log.attributes = logAttributes; } + +/** + * Extracts primitive attributes from a scope and merges them into the target object. + * Only string, number, and boolean attribute values are included. + * + * @param scope - The scope to extract attributes from + * @param target - The target object to merge attributes into + */ +function extractScopeAttributes( + scope: ReturnType, + target: Record, +): void { + if (scope && typeof scope.getScopeData === 'function') { + const scopeData = scope.getScopeData(); + const scopeAttrs = scopeData.attributes || {}; + Object.keys(scopeAttrs).forEach((key: string) => { + const value = scopeAttrs[key]; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + target[key] = value; + } + }); + } +} + +/** + * Collects attributes from all active scopes (global, isolation, and current). + * Only string, number, and boolean attribute values are supported. + * Attributes are merged in order of precedence: global < isolation < current. + * + * @returns A merged object containing all scope attributes. + */ +function collectScopeAttributes(): Record { + const attributes: Record = {}; + + // Collect attributes from all scopes in order of precedence + extractScopeAttributes(getGlobalScope(), attributes); + extractScopeAttributes(getIsolationScope(), attributes); + extractScopeAttributes(getCurrentScope(), attributes); + + return attributes; +} diff --git a/packages/core/test/integrations/logEnricherIntegration.test.ts b/packages/core/test/integrations/logEnricherIntegration.test.ts index 3b83726fca..12a6971857 100644 --- a/packages/core/test/integrations/logEnricherIntegration.test.ts +++ b/packages/core/test/integrations/logEnricherIntegration.test.ts @@ -1,5 +1,5 @@ import type { Client, Log } from '@sentry/core'; -import { debug } from '@sentry/core'; +import { debug, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; import { logEnricherIntegration } from '../../src/js/integrations/logEnricherIntegration'; import type { NativeDeviceContextsResponse } from '../../src/js/NativeRNSentry'; import { NATIVE } from '../../src/js/wrapper'; @@ -11,6 +11,9 @@ jest.mock('@sentry/core', () => ({ debug: { log: jest.fn(), }, + getCurrentScope: jest.fn(), + getGlobalScope: jest.fn(), + getIsolationScope: jest.fn(), })); const mockLogger = debug as jest.Mocked; @@ -49,6 +52,13 @@ describe('LogEnricher Integration', () => { } as unknown as jest.Mocked; (NATIVE as jest.Mocked).fetchNativeLogAttributes = mockFetchNativeLogAttributes; + + // Mock scope methods + (getCurrentScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ attributes: {} }), + }); + (getGlobalScope as jest.Mock).mockReturnValue({ getScopeData: jest.fn().mockReturnValue({ attributes: {} }) }); + (getIsolationScope as jest.Mock).mockReturnValue({ getScopeData: jest.fn().mockReturnValue({ attributes: {} }) }); }); afterEach(() => { @@ -516,4 +526,229 @@ describe('LogEnricher Integration', () => { expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay'); }); }); + + describe('scope attributes', () => { + let logHandler: (log: Log) => void; + let mockLog: Log; + + beforeEach(async () => { + const integration = logEnricherIntegration(); + + const mockNativeResponse: NativeDeviceContextsResponse = { + contexts: { + device: { + brand: 'Apple', + model: 'iPhone 14', + } as Record, + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(mockNativeResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + logHandler = beforeCaptureLogCall[1]; + + mockLog = { + message: 'Test log message', + level: 'info', + attributes: {}, + }; + }); + + it('should apply attributes from global scope to logs', () => { + (getGlobalScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ + attributes: { + is_admin: true, + auth_provider: 'google', + }, + }), + }); + + logHandler(mockLog); + + expect(mockLog.attributes).toMatchObject({ + is_admin: true, + auth_provider: 'google', + }); + }); + + it('should apply attributes from isolation scope to logs', () => { + (getIsolationScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ + attributes: { + session_id: 'abc123', + user_tier: 'premium', + }, + }), + }); + + logHandler(mockLog); + + expect(mockLog.attributes).toMatchObject({ + session_id: 'abc123', + user_tier: 'premium', + }); + }); + + it('should apply attributes from current scope to logs', () => { + (getCurrentScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ + attributes: { + step: 'authentication', + attempt: 1, + }, + }), + }); + + logHandler(mockLog); + + expect(mockLog.attributes).toMatchObject({ + step: 'authentication', + attempt: 1, + }); + }); + + it('should merge attributes from all scopes with correct precedence', () => { + (getGlobalScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ + attributes: { + is_admin: true, + environment: 'production', + }, + }), + }); + + (getIsolationScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ + attributes: { + environment: 'staging', + session_id: 'xyz789', + }, + }), + }); + + (getCurrentScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ + attributes: { + environment: 'development', + step: 'login', + }, + }), + }); + + logHandler(mockLog); + + expect(mockLog.attributes).toMatchObject({ + is_admin: true, + environment: 'development', // Current scope wins + session_id: 'xyz789', + step: 'login', + }); + }); + + it('should only include string, number, and boolean attribute values', () => { + (getCurrentScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ + attributes: { + stringAttr: 'value', + numberAttr: 42, + boolAttr: false, + objectAttr: { nested: 'object' }, // Should be filtered out + arrayAttr: [1, 2, 3], // Should be filtered out + nullAttr: null, // Should be filtered out + undefinedAttr: undefined, // Should be filtered out + }, + }), + }); + + logHandler(mockLog); + + expect(mockLog.attributes).toMatchObject({ + stringAttr: 'value', + numberAttr: 42, + boolAttr: false, + }); + expect(mockLog.attributes).not.toHaveProperty('objectAttr'); + expect(mockLog.attributes).not.toHaveProperty('arrayAttr'); + expect(mockLog.attributes).not.toHaveProperty('nullAttr'); + expect(mockLog.attributes).not.toHaveProperty('undefinedAttr'); + }); + + it('should not override existing log attributes with scope attributes', () => { + (getCurrentScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ + attributes: { + step: 'authentication', + user_id: 'scope-user', + }, + }), + }); + + mockLog.attributes = { + user_id: 'log-user', // This should not be overridden + custom: 'value', + }; + + logHandler(mockLog); + + expect(mockLog.attributes).toMatchObject({ + user_id: 'log-user', // Original value preserved + custom: 'value', + step: 'authentication', + }); + }); + + it('should handle scopes without getScopeData method', () => { + (getCurrentScope as jest.Mock).mockReturnValue({}); + (getGlobalScope as jest.Mock).mockReturnValue({}); + (getIsolationScope as jest.Mock).mockReturnValue({}); + + logHandler(mockLog); + + // Should not throw and should still add device attributes + expect(mockLog.attributes).toMatchObject({ + 'device.brand': 'Apple', + 'device.model': 'iPhone 14', + }); + }); + + it('should handle null or undefined scopes', () => { + (getCurrentScope as jest.Mock).mockReturnValue(null); + (getGlobalScope as jest.Mock).mockReturnValue(undefined); + (getIsolationScope as jest.Mock).mockReturnValue(null); + + logHandler(mockLog); + + // Should not throw and should still add device attributes + expect(mockLog.attributes).toMatchObject({ + 'device.brand': 'Apple', + 'device.model': 'iPhone 14', + }); + }); + + it('should apply scope attributes before device attributes so they can be overridden', () => { + (getCurrentScope as jest.Mock).mockReturnValue({ + getScopeData: jest.fn().mockReturnValue({ + attributes: { + 'device.brand': 'CustomBrand', // Should be overridden by native + }, + }), + }); + + logHandler(mockLog); + + expect(mockLog.attributes).toMatchObject({ + 'device.brand': 'Apple', // Native value should override + 'device.model': 'iPhone 14', + }); + }); + }); }); diff --git a/samples/expo/utils/setScopeProperties.ts b/samples/expo/utils/setScopeProperties.ts index 1fbfca4238..c698e6d40a 100644 --- a/samples/expo/utils/setScopeProperties.ts +++ b/samples/expo/utils/setScopeProperties.ts @@ -44,6 +44,18 @@ export const setScopeProperties = () => { undefinedTest: undefined, }); + // Set scope attributes that will be automatically applied to logs + Sentry.getGlobalScope().setAttributes({ + is_admin: true, + auth_provider: 'auth', + }); + + // Set scope attributes on the current scope + Sentry.getCurrentScope().setAttributes({ + session_type: 'test', + request_count: 1, + }); + Sentry.addBreadcrumb({ level: 'info' as SeverityLevel, message: `TEST-BREADCRUMB-INFO: ${dateString}`, diff --git a/samples/react-native-macos/src/setScopeProperties.ts b/samples/react-native-macos/src/setScopeProperties.ts index 1fbfca4238..a616f3e418 100644 --- a/samples/react-native-macos/src/setScopeProperties.ts +++ b/samples/react-native-macos/src/setScopeProperties.ts @@ -44,6 +44,18 @@ export const setScopeProperties = () => { undefinedTest: undefined, }); + // Set scope attributes that will be automatically applied to logs + Sentry.getGlobalScope().setAttributes({ + is_admin: true, + auth_provider: 'google', + }); + + // Set scope attributes on the current scope + Sentry.getCurrentScope().setAttributes({ + session_type: 'test', + request_count: 42, + }); + Sentry.addBreadcrumb({ level: 'info' as SeverityLevel, message: `TEST-BREADCRUMB-INFO: ${dateString}`, diff --git a/samples/react-native/src/setScopeProperties.ts b/samples/react-native/src/setScopeProperties.ts index 1fbfca4238..5693fc1776 100644 --- a/samples/react-native/src/setScopeProperties.ts +++ b/samples/react-native/src/setScopeProperties.ts @@ -44,6 +44,16 @@ export const setScopeProperties = () => { undefinedTest: undefined, }); + Sentry.getGlobalScope().setAttributes({ + is_admin: true, + auth_provider: 'auth', + }); + + Sentry.getCurrentScope().setAttributes({ + session_type: 'test', + request_count: 1, + }); + Sentry.addBreadcrumb({ level: 'info' as SeverityLevel, message: `TEST-BREADCRUMB-INFO: ${dateString}`, From 06cc30237fb2f496f4d9577876d856d932e82cc4 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 27 Jan 2026 10:31:45 +0100 Subject: [PATCH 2/8] Scope attributes sync (without native) --- packages/core/src/js/NativeRNSentry.ts | 2 + packages/core/src/js/scopeSync.ts | 24 +++++++++ packages/core/src/js/wrapper.ts | 39 +++++++++++++++ packages/core/test/mockWrapper.ts | 3 ++ packages/core/test/scopeSync.test.ts | 69 ++++++++++++++++++++++++++ 5 files changed, 137 insertions(+) diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 3e75967c27..afd8fba03d 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -32,6 +32,8 @@ export interface Spec extends TurboModule { setContext(key: string, value: UnsafeObject | null): void; setExtra(key: string, value: string): void; setTag(key: string, value: string): void; + setAttribute(key: string, value: string): void; + setAttributes(attributes: UnsafeObject): void; enableNativeFramesTracking(): void; fetchModules(): Promise; fetchViewHierarchy(): Promise; diff --git a/packages/core/src/js/scopeSync.ts b/packages/core/src/js/scopeSync.ts index 5c6537d89e..1fe6b65265 100644 --- a/packages/core/src/js/scopeSync.ts +++ b/packages/core/src/js/scopeSync.ts @@ -79,4 +79,28 @@ export function enableSyncToNative(scope: Scope): void { NATIVE.setContext(key, context); return original.call(scope, key, context); }); + + fillTyped(scope, 'setAttribute', original => (key: string, value: unknown): Scope => { + // Only sync primitive types + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + NATIVE.setAttribute(key, value); + } + return original.call(scope, key, value); + }); + + fillTyped(scope, 'setAttributes', original => (attributes: Record): Scope => { + // Filter to only primitive types + const primitiveAttrs: Record = {}; + Object.keys(attributes).forEach(key => { + const value = attributes[key]; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + primitiveAttrs[key] = value; + } + }); + + if (Object.keys(primitiveAttrs).length > 0) { + NATIVE.setAttributes(primitiveAttrs); + } + return original.call(scope, attributes); + }); } diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 4089bc5c75..55fcb3a2e8 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -100,6 +100,8 @@ interface SentryNativeWrapper { setExtra(key: string, extra: unknown): void; setUser(user: User | null): void; setTag(key: string, value?: string): void; + setAttribute(key: string, value: string | number | boolean): void; + setAttributes(attributes: Record): void; nativeCrash(): void; @@ -551,6 +553,43 @@ export const NATIVE: SentryNativeWrapper = { } }, + /** + * Sets an attribute on the native scope. + * @param key string + * @param value primitive value (string, number, or boolean) + */ + setAttribute(key: string, value: string | number | boolean): void { + if (!this.enableNative) { + return; + } + if (!this._isModuleLoaded(RNSentry)) { + throw this._NativeClientError; + } + + const stringifiedValue = this.primitiveProcessor(value); + RNSentry.setAttribute(key, stringifiedValue); + }, + + /** + * Sets multiple attributes on the native scope. + * @param attributes key-value map of attributes (only string, number, and boolean values) + */ + setAttributes(attributes: Record): void { + if (!this.enableNative) { + return; + } + if (!this._isModuleLoaded(RNSentry)) { + throw this._NativeClientError; + } + + const serializedAttributes: Record = {}; + Object.keys(attributes).forEach(key => { + serializedAttributes[key] = this.primitiveProcessor(attributes[key]); + }); + + RNSentry.setAttributes(serializedAttributes); + }, + /** * Closes the Native Layer SDK */ diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index 83d15681b5..89c7486575 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -41,6 +41,8 @@ const NATIVE: MockInterface = { setExtra: jest.fn(), setUser: jest.fn(), setTag: jest.fn(), + setAttribute: jest.fn(), + setAttributes: jest.fn(), nativeCrash: jest.fn(), @@ -92,6 +94,7 @@ NATIVE.crashedLastRun.mockResolvedValue(false); NATIVE.popTimeToDisplayFor.mockResolvedValue(null); NATIVE.getNewScreenTimeToDisplay.mockResolvedValue(null); NATIVE.primitiveProcessor.mockReturnValue(''); +NATIVE.fetchNativeLogAttributes = jest.fn().mockResolvedValue({}); export const getRNSentryModule = jest.fn(); export { NATIVE }; diff --git a/packages/core/test/scopeSync.test.ts b/packages/core/test/scopeSync.test.ts index abfba69291..43a8146648 100644 --- a/packages/core/test/scopeSync.test.ts +++ b/packages/core/test/scopeSync.test.ts @@ -116,6 +116,8 @@ describe('ScopeSync', () => { let setExtrasScopeSpy: jest.SpyInstance; let addBreadcrumbScopeSpy: jest.SpyInstance; let setContextScopeSpy: jest.SpyInstance; + let setAttributeScopeSpy: jest.SpyInstance; + let setAttributesScopeSpy: jest.SpyInstance; beforeAll(() => { const testScope = SentryCore.getIsolationScope(); @@ -126,6 +128,8 @@ describe('ScopeSync', () => { setExtrasScopeSpy = jest.spyOn(testScope, 'setExtras'); addBreadcrumbScopeSpy = jest.spyOn(testScope, 'addBreadcrumb'); setContextScopeSpy = jest.spyOn(testScope, 'setContext'); + setAttributeScopeSpy = jest.spyOn(testScope, 'setAttribute'); + setAttributesScopeSpy = jest.spyOn(testScope, 'setAttributes'); }); beforeEach(() => { @@ -214,5 +218,70 @@ describe('ScopeSync', () => { expect(NATIVE.setContext).toHaveBeenCalledExactlyOnceWith('key', { key: 'value' }); expect(setContextScopeSpy).toHaveBeenCalledExactlyOnceWith('key', { key: 'value' }); }); + + it('setAttribute', () => { + expect(SentryCore.getIsolationScope().setAttribute).not.toBe(setAttributeScopeSpy); + + SentryCore.getIsolationScope().setAttribute('session_id', 'abc123'); + expect(NATIVE.setAttribute).toHaveBeenCalledExactlyOnceWith('session_id', 'abc123'); + expect(setAttributeScopeSpy).toHaveBeenCalledExactlyOnceWith('session_id', 'abc123'); + }); + + it('setAttribute with number', () => { + SentryCore.getIsolationScope().setAttribute('request_count', 42); + expect(NATIVE.setAttribute).toHaveBeenCalledExactlyOnceWith('request_count', 42); + }); + + it('setAttribute with boolean', () => { + SentryCore.getIsolationScope().setAttribute('is_admin', true); + expect(NATIVE.setAttribute).toHaveBeenCalledExactlyOnceWith('is_admin', true); + }); + + it('setAttribute with non-primitive does not sync to native', () => { + SentryCore.getIsolationScope().setAttribute('complex', { nested: 'object' }); + expect(NATIVE.setAttribute).not.toHaveBeenCalled(); + }); + + it('setAttributes', () => { + expect(SentryCore.getIsolationScope().setAttributes).not.toBe(setAttributesScopeSpy); + + SentryCore.getIsolationScope().setAttributes({ + session_type: 'test', + request_count: 42, + is_admin: true, + }); + expect(NATIVE.setAttributes).toHaveBeenCalledExactlyOnceWith({ + session_type: 'test', + request_count: 42, + is_admin: true, + }); + expect(setAttributesScopeSpy).toHaveBeenCalledExactlyOnceWith({ + session_type: 'test', + request_count: 42, + is_admin: true, + }); + }); + + it('setAttributes filters non-primitive values', () => { + SentryCore.getIsolationScope().setAttributes({ + session_type: 'test', + request_count: 42, + complex: { nested: 'object' }, + is_admin: true, + }); + expect(NATIVE.setAttributes).toHaveBeenCalledExactlyOnceWith({ + session_type: 'test', + request_count: 42, + is_admin: true, + }); + }); + + it('setAttributes does not sync to native if all values are non-primitive', () => { + SentryCore.getIsolationScope().setAttributes({ + complex1: { nested: 'object' }, + complex2: ['array'], + }); + expect(NATIVE.setAttributes).not.toHaveBeenCalled(); + }); }); }); From 3c834cf84c983e97e21afcab3c27b94384571dec Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 27 Jan 2026 10:48:12 +0100 Subject: [PATCH 3/8] Sync scope attributes to native --- .../io/sentry/react/RNSentryModuleImpl.java | 19 +++++++++++++++++++ .../java/io/sentry/react/RNSentryModule.java | 10 ++++++++++ .../java/io/sentry/react/RNSentryModule.java | 15 +++++++++++++++ packages/core/ios/RNSentry.mm | 14 ++++++++++++++ 4 files changed, 58 insertions(+) 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 5a24e7d54c..6a572d277f 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 @@ -933,6 +933,25 @@ public void setTag(String key, String value) { }); } + public void setAttribute(String key, String value) { + Sentry.configureScope( + scope -> { + scope.setAttribute(key, value); + }); + } + + public void setAttributes(ReadableMap attributes) { + Sentry.configureScope( + scope -> { + final ReadableMapKeySetIterator iterator = attributes.keySetIterator(); + while (iterator.hasNextKey()) { + final String key = iterator.nextKey(); + final String value = attributes.getString(key); + scope.setAttribute(key, value); + } + }); + } + public void closeNativeSdk(Promise promise) { Sentry.close(); 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 993969d830..b928d2d9c4 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 @@ -112,6 +112,16 @@ public void setTag(String key, String value) { this.impl.setTag(key, value); } + @Override + public void setAttribute(String key, String value) { + this.impl.setAttribute(key, value); + } + + @Override + public void setAttributes(ReadableMap attributes) { + this.impl.setAttributes(attributes); + } + @Override public void closeNativeSdk(Promise promise) { this.impl.closeNativeSdk(promise); diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 103afeb890..0488e143c9 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -112,6 +112,16 @@ public void setTag(String key, String value) { this.impl.setTag(key, value); } + @ReactMethod + public void setAttribute(String key, String value) { + this.impl.setAttribute(key, value); + } + + @ReactMethod + public void setAttributes(ReadableMap attributes) { + this.impl.setAttributes(attributes); + } + @ReactMethod public void closeNativeSdk(Promise promise) { this.impl.closeNativeSdk(promise); @@ -132,6 +142,11 @@ public void fetchNativeDeviceContexts(Promise promise) { this.impl.fetchNativeDeviceContexts(promise); } + @ReactMethod + public void fetchNativeLogAttributes(Promise promise) { + this.impl.fetchNativeLogAttributes(promise); + } + @ReactMethod public void fetchNativeSdkInfo(Promise promise) { this.impl.fetchNativeSdkInfo(promise); diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index f5fefc0cd6..29c9969911 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -723,6 +723,20 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys configureScope:^(SentryScope *_Nonnull scope) { [scope setTagValue:value forKey:key]; }]; } +RCT_EXPORT_METHOD(setAttribute : (NSString *)key value : (NSString *)value) +{ + [SentrySDKWrapper + configureScope:^(SentryScope *_Nonnull scope) { [scope setAttribute:value forKey:key]; }]; +} + +RCT_EXPORT_METHOD(setAttributes : (NSDictionary *)attributes) +{ + [SentrySDKWrapper configureScope:^(SentryScope *_Nonnull scope) { + [attributes enumerateKeysAndObjectsUsingBlock:^( + NSString *key, NSString *value, BOOL *stop) { [scope setAttribute:value forKey:key]; }]; + }]; +} + RCT_EXPORT_METHOD(crash) { [SentrySDKWrapper crash]; } RCT_EXPORT_METHOD( From c588f82f34148c7cd9a713820e033805f5db3754 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 27 Jan 2026 12:56:53 +0100 Subject: [PATCH 4/8] setContext for android instead of attributes --- .../io/sentry/react/RNSentryModuleImpl.java | 145 +++++++++--------- 1 file changed, 73 insertions(+), 72 deletions(-) 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 6a572d277f..d27fd1fa46 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 @@ -117,7 +117,8 @@ public class RNSentryModuleImpl { private FrameMetricsAggregator frameMetricsAggregator = null; private boolean androidXAvailable; - @VisibleForTesting static long lastStartTimestampMs = -1; + @VisibleForTesting + static long lastStartTimestampMs = -1; // 700ms to constitute frozen frames. private static final int FROZEN_FRAME_THRESHOLD = 700; @@ -127,7 +128,8 @@ public class RNSentryModuleImpl { private static final int SCREENSHOT_TIMEOUT_SECONDS = 2; /** - * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 to avoid possible + * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 + * to avoid possible * lockstep sampling. More on * https://stackoverflow.com/questions/45470758/what-is-lockstep-sampling */ @@ -172,13 +174,12 @@ private ReactApplicationContext getReactApplicationContext() { } private void initFragmentInitialFrameTracking() { - final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer = - new RNSentryReactFragmentLifecycleTracer(buildInfo, emitNewFrameEvent, logger); + final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer = new RNSentryReactFragmentLifecycleTracer( + buildInfo, emitNewFrameEvent, logger); final @Nullable FragmentActivity fragmentActivity = (FragmentActivity) getCurrentActivity(); if (fragmentActivity != null) { - final @Nullable FragmentManager supportFragmentManager = - fragmentActivity.getSupportFragmentManager(); + final @Nullable FragmentManager supportFragmentManager = fragmentActivity.getSupportFragmentManager(); if (supportFragmentManager != null) { supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); } @@ -186,8 +187,8 @@ private void initFragmentInitialFrameTracking() { } private void initFragmentReplayTracking() { - final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = - new RNSentryReplayFragmentLifecycleTracer(logger); + final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = new RNSentryReplayFragmentLifecycleTracer( + logger); final @Nullable Activity currentActivity = getCurrentActivity(); if (!(currentActivity instanceof FragmentActivity)) { @@ -195,8 +196,7 @@ private void initFragmentReplayTracking() { } final @NotNull FragmentActivity fragmentActivity = (FragmentActivity) currentActivity; - final @Nullable FragmentManager supportFragmentManager = - fragmentActivity.getSupportFragmentManager(); + final @Nullable FragmentManager supportFragmentManager = fragmentActivity.getSupportFragmentManager(); if (supportFragmentManager != null) { supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); } @@ -226,7 +226,8 @@ protected Context getApplicationContext() { protected void getSentryAndroidOptions( @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions, ILogger logger) { - @Nullable SdkVersion sdkVersion = options.getSdkVersion(); + @Nullable + SdkVersion sdkVersion = options.getSdkVersion(); if (sdkVersion == null) { sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); } else { @@ -328,8 +329,7 @@ protected void getSentryAndroidOptions( // Check if the replay integration is available on the classpath. It's already // kept from R8 // shrinking by sentry-android-core - final boolean isReplayAvailable = - loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger); + final boolean isReplayAvailable = loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger); if (isReplayEnabled(replayOptions) && isReplayAvailable) { options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); initFragmentReplayTracking(); @@ -394,13 +394,11 @@ private boolean isReplayEnabled(SentryReplayOptions replayOptions) { } private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { - final SdkVersion replaySdkVersion = - new SdkVersion( - RNSentryVersion.REACT_NATIVE_SDK_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + final SdkVersion replaySdkVersion = new SdkVersion( + RNSentryVersion.REACT_NATIVE_SDK_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); @NotNull - final SentryReplayOptions androidReplayOptions = - new SentryReplayOptions(false, replaySdkVersion); + final SentryReplayOptions androidReplayOptions = new SentryReplayOptions(false, replaySdkVersion); if (!(rnOptions.hasKey("replaysSessionSampleRate") || rnOptions.hasKey("replaysOnErrorSampleRate"))) { @@ -425,7 +423,8 @@ private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { if (!rnOptions.hasKey("mobileReplayOptions")) { return androidReplayOptions; } - @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); + @Nullable + final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); if (rnMobileReplayOptions == null) { return androidReplayOptions; } @@ -437,17 +436,15 @@ private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { !rnMobileReplayOptions.hasKey("maskAllImages") || rnMobileReplayOptions.getBoolean("maskAllImages")); - final boolean redactVectors = - !rnMobileReplayOptions.hasKey("maskAllVectors") - || rnMobileReplayOptions.getBoolean("maskAllVectors"); + final boolean redactVectors = !rnMobileReplayOptions.hasKey("maskAllVectors") + || rnMobileReplayOptions.getBoolean("maskAllVectors"); if (redactVectors) { androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg } if (rnMobileReplayOptions.hasKey("screenshotStrategy")) { final String screenshotStrategyString = rnMobileReplayOptions.getString("screenshotStrategy"); - final ScreenshotStrategyType screenshotStrategy = - parseScreenshotStrategy(screenshotStrategyString); + final ScreenshotStrategyType screenshotStrategy = parseScreenshotStrategy(screenshotStrategyString); androidReplayOptions.setScreenshotStrategy(screenshotStrategy); } @@ -493,7 +490,8 @@ private void configureAndroidProfiling( return; } - @Nullable final ReadableMap experiments = rnOptions.getMap("_experiments"); + @Nullable + final ReadableMap experiments = rnOptions.getMap("_experiments"); if (experiments == null || !experiments.hasKey("androidProfilingOptions")) { return; } @@ -506,8 +504,7 @@ private void configureAndroidProfiling( // Set profile session sample rate if (androidProfilingOptions.hasKey("profileSessionSampleRate")) { - final double profileSessionSampleRate = - androidProfilingOptions.getDouble("profileSessionSampleRate"); + final double profileSessionSampleRate = androidProfilingOptions.getDouble("profileSessionSampleRate"); options.setProfileSessionSampleRate(profileSessionSampleRate); logger.log( SentryLevel.INFO, @@ -596,12 +593,10 @@ protected void fetchNativeAppStart( return; } - WritableMap mutableMeasurement = - (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); + WritableMap mutableMeasurement = (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); long currentStartTimestampMs = metrics.getAppStartTimeSpan().getStartTimestampMs(); - boolean hasFetched = - lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs; + boolean hasFetched = lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs; mutableMeasurement.putBoolean("has_fetched", hasFetched); if (lastStartTimestampMs < 0) { @@ -734,12 +729,11 @@ public void captureScreenshot(Promise promise) { private static byte[] takeScreenshotOnUiThread(Activity activity) { CountDownLatch doneSignal = new CountDownLatch(1); - final byte[][] bytesWrapper = {{}}; // wrapper to be able to set the value in the runnable - final Runnable runTakeScreenshot = - () -> { - bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo); - doneSignal.countDown(); - }; + final byte[][] bytesWrapper = { {} }; // wrapper to be able to set the value in the runnable + final Runnable runTakeScreenshot = () -> { + bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo); + doneSignal.countDown(); + }; if (UiThreadUtil.isOnUiThread()) { runTakeScreenshot.run(); @@ -759,8 +753,7 @@ private static byte[] takeScreenshotOnUiThread(Activity activity) { public void fetchViewHierarchy(Promise promise) { final @Nullable Activity activity = getCurrentActivity(); - final @Nullable ViewHierarchy viewHierarchy = - ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger); + final @Nullable ViewHierarchy viewHierarchy = ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger); if (viewHierarchy == null) { logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy."); promise.resolve(null); @@ -768,8 +761,7 @@ public void fetchViewHierarchy(Promise promise) { } ISerializer serializer = ScopesAdapter.getInstance().getOptions().getSerializer(); - final @Nullable byte[] bytes = - JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); + final @Nullable byte[] bytes = JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); if (bytes == null) { logger.log(SentryLevel.ERROR, "Could not serialize ViewHierarchy."); promise.resolve(null); @@ -936,19 +928,36 @@ public void setTag(String key, String value) { public void setAttribute(String key, String value) { Sentry.configureScope( scope -> { - scope.setAttribute(key, value); + // Android SDK doesn't have setAttribute yet, store in "attributes" context + // TODO(alwx): remove once setAttribute is implemented + final Map existingAttributes = new HashMap<>(); + final Object existing = scope.getContexts().get("attributes"); + if (existing instanceof Map) { + existingAttributes.putAll((Map) existing); + } + existingAttributes.put(key, value); + scope.setContexts("attributes", existingAttributes); }); } public void setAttributes(ReadableMap attributes) { Sentry.configureScope( scope -> { + // Android SDK doesn't have setAttribute yet, store in "attributes" context + // TODO(alwx): remove once setAttribute is implemented + final Map existingAttributes = new HashMap<>(); + final Object existing = scope.getContexts().get("attributes"); + if (existing instanceof Map) { + existingAttributes.putAll((Map) existing); + } + final ReadableMapKeySetIterator iterator = attributes.keySetIterator(); while (iterator.hasNextKey()) { final String key = iterator.nextKey(); final String value = attributes.getString(key); - scope.setAttribute(key, value); + existingAttributes.put(key, value); } + scope.setContexts("attributes", existingAttributes); }); } @@ -998,8 +1007,7 @@ public void getNewScreenTimeToDisplay(Promise promise) { private String getProfilingTracesDirPath() { if (cacheDirPath == null) { - cacheDirPath = - new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); + cacheDirPath = new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); } File profilingTraceDir = new File(cacheDirPath, "profiling_trace"); profilingTraceDir.mkdirs(); @@ -1012,13 +1020,12 @@ private void initializeAndroidProfiler() { } final String tracesFilesDirPath = getProfilingTracesDirPath(); - androidProfiler = - new AndroidProfiler( - tracesFilesDirPath, - (int) SECONDS.toMicros(1) / profilingTracesHz, - new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), - executorService, - logger); + androidProfiler = new AndroidProfiler( + tracesFilesDirPath, + (int) SECONDS.toMicros(1) / profilingTracesHz, + new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), + executorService, + logger); } public WritableMap startProfiling(boolean platformProfilers) { @@ -1052,9 +1059,8 @@ public WritableMap stopProfiling() { } HermesSamplingProfiler.disable(); - output = - File.createTempFile( - "sampling-profiler-trace", ".cpuprofile", reactApplicationContext.getCacheDir()); + output = File.createTempFile( + "sampling-profiler-trace", ".cpuprofile", reactApplicationContext.getCacheDir()); if (isDebug) { logger.log(SentryLevel.INFO, "Profile saved to: " + output.getAbsolutePath()); } @@ -1064,10 +1070,8 @@ public WritableMap stopProfiling() { if (end != null) { WritableMap androidProfile = new WritableNativeMap(); - byte[] androidProfileBytes = - FileUtils.readBytesFromFile(end.traceFile.getPath(), maxTraceFileSize); - String base64AndroidProfile = - Base64.encodeToString(androidProfileBytes, NO_WRAP | NO_PADDING); + byte[] androidProfileBytes = FileUtils.readBytesFromFile(end.traceFile.getPath(), maxTraceFileSize); + String base64AndroidProfile = Base64.encodeToString(androidProfileBytes, NO_WRAP | NO_PADDING); androidProfile.putString("sampled_profile", base64AndroidProfile); androidProfile.putInt("android_api_level", buildInfo.getSdkInfoVersion()); @@ -1096,8 +1100,8 @@ public WritableMap stopProfiling() { return proguardUuid; } isProguardDebugMetaLoaded = true; - final @Nullable List debugMetaList = - new AssetsDebugMetaLoader(this.getReactApplicationContext(), logger).loadDebugMeta(); + final @Nullable List debugMetaList = new AssetsDebugMetaLoader(this.getReactApplicationContext(), + logger).loadDebugMeta(); if (debugMetaList == null) { return null; } @@ -1115,7 +1119,7 @@ public WritableMap stopProfiling() { } private String readStringFromFile(File path) throws IOException { - try (BufferedReader br = new BufferedReader(new FileReader(path)); ) { + try (BufferedReader br = new BufferedReader(new FileReader(path));) { final StringBuilder text = new StringBuilder(); String line; @@ -1165,8 +1169,8 @@ protected void fetchNativeDeviceContexts( } } - final @NotNull Map serialized = - InternalSentrySdk.serializeScope(context, (SentryAndroidOptions) options, currentScope); + final @NotNull Map serialized = InternalSentrySdk.serializeScope(context, + (SentryAndroidOptions) options, currentScope); final @Nullable Object deviceContext = RNSentryMapConverter.convertToWritable(serialized); promise.resolve(deviceContext); } @@ -1182,9 +1186,8 @@ protected void fetchNativeLogContexts( return; } - Object contextsObj = - InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) - .get("contexts"); + Object contextsObj = InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) + .get("contexts"); if (!(contextsObj instanceof Map)) { promise.resolve(null); @@ -1213,8 +1216,7 @@ protected void fetchNativeLogContexts( } public void fetchNativeSdkInfo(Promise promise) { - final @Nullable SdkVersion sdkVersion = - ScopesAdapter.getInstance().getOptions().getSdkVersion(); + final @Nullable SdkVersion sdkVersion = ScopesAdapter.getInstance().getOptions().getSdkVersion(); if (sdkVersion == null) { promise.resolve(null); } else { @@ -1232,8 +1234,7 @@ public String fetchNativePackageName() { public void getDataFromUri(String uri, Promise promise) { try { Uri contentUri = Uri.parse(uri); - try (InputStream is = - getReactApplicationContext().getContentResolver().openInputStream(contentUri)) { + try (InputStream is = getReactApplicationContext().getContentResolver().openInputStream(contentUri)) { if (is == null) { String msg = "File not found for uri: " + uri; logger.log(SentryLevel.ERROR, msg); From 14ecf4f5bf69ba45d165df9b3f15d85c906a4522 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 27 Jan 2026 12:59:22 +0100 Subject: [PATCH 5/8] Changelog entry --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbfb633f8d..6d5b5302c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,7 @@ ## Unreleased -### Features - +- Add support for applying scope attributes to logs ([#5579](https://github.com/getsentry/sentry-react-native/pull/5579)) - Add experimental `sentry-span-attributes` prop to attach custom attributes to user interaction spans ([#5569](https://github.com/getsentry/sentry-react-native/pull/5569)) ```tsx Date: Tue, 27 Jan 2026 15:52:41 +0100 Subject: [PATCH 6/8] Changelog fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5b5302c3..1f41b22534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +### Features + - Add support for applying scope attributes to logs ([#5579](https://github.com/getsentry/sentry-react-native/pull/5579)) - Add experimental `sentry-span-attributes` prop to attach custom attributes to user interaction spans ([#5569](https://github.com/getsentry/sentry-react-native/pull/5569)) ```tsx From bbdb7a5d3d6033b1df42d57ba4d8f17d0c9ec40a Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 27 Jan 2026 15:57:58 +0100 Subject: [PATCH 7/8] Placeholders for the missing functions --- .../io/sentry/react/RNSentryModuleImpl.java | 168 +++++++++--------- packages/core/ios/RNSentry.mm | 10 +- 2 files changed, 90 insertions(+), 88 deletions(-) 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 d27fd1fa46..11a4b40c53 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 @@ -117,8 +117,7 @@ public class RNSentryModuleImpl { private FrameMetricsAggregator frameMetricsAggregator = null; private boolean androidXAvailable; - @VisibleForTesting - static long lastStartTimestampMs = -1; + @VisibleForTesting static long lastStartTimestampMs = -1; // 700ms to constitute frozen frames. private static final int FROZEN_FRAME_THRESHOLD = 700; @@ -128,8 +127,7 @@ public class RNSentryModuleImpl { private static final int SCREENSHOT_TIMEOUT_SECONDS = 2; /** - * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 - * to avoid possible + * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 to avoid possible * lockstep sampling. More on * https://stackoverflow.com/questions/45470758/what-is-lockstep-sampling */ @@ -174,12 +172,13 @@ private ReactApplicationContext getReactApplicationContext() { } private void initFragmentInitialFrameTracking() { - final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer = new RNSentryReactFragmentLifecycleTracer( - buildInfo, emitNewFrameEvent, logger); + final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer = + new RNSentryReactFragmentLifecycleTracer(buildInfo, emitNewFrameEvent, logger); final @Nullable FragmentActivity fragmentActivity = (FragmentActivity) getCurrentActivity(); if (fragmentActivity != null) { - final @Nullable FragmentManager supportFragmentManager = fragmentActivity.getSupportFragmentManager(); + final @Nullable FragmentManager supportFragmentManager = + fragmentActivity.getSupportFragmentManager(); if (supportFragmentManager != null) { supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); } @@ -187,8 +186,8 @@ private void initFragmentInitialFrameTracking() { } private void initFragmentReplayTracking() { - final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = new RNSentryReplayFragmentLifecycleTracer( - logger); + final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = + new RNSentryReplayFragmentLifecycleTracer(logger); final @Nullable Activity currentActivity = getCurrentActivity(); if (!(currentActivity instanceof FragmentActivity)) { @@ -196,7 +195,8 @@ private void initFragmentReplayTracking() { } final @NotNull FragmentActivity fragmentActivity = (FragmentActivity) currentActivity; - final @Nullable FragmentManager supportFragmentManager = fragmentActivity.getSupportFragmentManager(); + final @Nullable FragmentManager supportFragmentManager = + fragmentActivity.getSupportFragmentManager(); if (supportFragmentManager != null) { supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); } @@ -226,8 +226,7 @@ protected Context getApplicationContext() { protected void getSentryAndroidOptions( @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions, ILogger logger) { - @Nullable - SdkVersion sdkVersion = options.getSdkVersion(); + @Nullable SdkVersion sdkVersion = options.getSdkVersion(); if (sdkVersion == null) { sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); } else { @@ -329,7 +328,8 @@ protected void getSentryAndroidOptions( // Check if the replay integration is available on the classpath. It's already // kept from R8 // shrinking by sentry-android-core - final boolean isReplayAvailable = loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger); + final boolean isReplayAvailable = + loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger); if (isReplayEnabled(replayOptions) && isReplayAvailable) { options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); initFragmentReplayTracking(); @@ -394,11 +394,13 @@ private boolean isReplayEnabled(SentryReplayOptions replayOptions) { } private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { - final SdkVersion replaySdkVersion = new SdkVersion( - RNSentryVersion.REACT_NATIVE_SDK_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + final SdkVersion replaySdkVersion = + new SdkVersion( + RNSentryVersion.REACT_NATIVE_SDK_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); @NotNull - final SentryReplayOptions androidReplayOptions = new SentryReplayOptions(false, replaySdkVersion); + final SentryReplayOptions androidReplayOptions = + new SentryReplayOptions(false, replaySdkVersion); if (!(rnOptions.hasKey("replaysSessionSampleRate") || rnOptions.hasKey("replaysOnErrorSampleRate"))) { @@ -423,8 +425,7 @@ private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { if (!rnOptions.hasKey("mobileReplayOptions")) { return androidReplayOptions; } - @Nullable - final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); + @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); if (rnMobileReplayOptions == null) { return androidReplayOptions; } @@ -436,15 +437,17 @@ private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { !rnMobileReplayOptions.hasKey("maskAllImages") || rnMobileReplayOptions.getBoolean("maskAllImages")); - final boolean redactVectors = !rnMobileReplayOptions.hasKey("maskAllVectors") - || rnMobileReplayOptions.getBoolean("maskAllVectors"); + final boolean redactVectors = + !rnMobileReplayOptions.hasKey("maskAllVectors") + || rnMobileReplayOptions.getBoolean("maskAllVectors"); if (redactVectors) { androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg } if (rnMobileReplayOptions.hasKey("screenshotStrategy")) { final String screenshotStrategyString = rnMobileReplayOptions.getString("screenshotStrategy"); - final ScreenshotStrategyType screenshotStrategy = parseScreenshotStrategy(screenshotStrategyString); + final ScreenshotStrategyType screenshotStrategy = + parseScreenshotStrategy(screenshotStrategyString); androidReplayOptions.setScreenshotStrategy(screenshotStrategy); } @@ -490,8 +493,7 @@ private void configureAndroidProfiling( return; } - @Nullable - final ReadableMap experiments = rnOptions.getMap("_experiments"); + @Nullable final ReadableMap experiments = rnOptions.getMap("_experiments"); if (experiments == null || !experiments.hasKey("androidProfilingOptions")) { return; } @@ -504,7 +506,8 @@ private void configureAndroidProfiling( // Set profile session sample rate if (androidProfilingOptions.hasKey("profileSessionSampleRate")) { - final double profileSessionSampleRate = androidProfilingOptions.getDouble("profileSessionSampleRate"); + final double profileSessionSampleRate = + androidProfilingOptions.getDouble("profileSessionSampleRate"); options.setProfileSessionSampleRate(profileSessionSampleRate); logger.log( SentryLevel.INFO, @@ -593,10 +596,12 @@ protected void fetchNativeAppStart( return; } - WritableMap mutableMeasurement = (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); + WritableMap mutableMeasurement = + (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); long currentStartTimestampMs = metrics.getAppStartTimeSpan().getStartTimestampMs(); - boolean hasFetched = lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs; + boolean hasFetched = + lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs; mutableMeasurement.putBoolean("has_fetched", hasFetched); if (lastStartTimestampMs < 0) { @@ -729,11 +734,12 @@ public void captureScreenshot(Promise promise) { private static byte[] takeScreenshotOnUiThread(Activity activity) { CountDownLatch doneSignal = new CountDownLatch(1); - final byte[][] bytesWrapper = { {} }; // wrapper to be able to set the value in the runnable - final Runnable runTakeScreenshot = () -> { - bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo); - doneSignal.countDown(); - }; + final byte[][] bytesWrapper = {{}}; // wrapper to be able to set the value in the runnable + final Runnable runTakeScreenshot = + () -> { + bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo); + doneSignal.countDown(); + }; if (UiThreadUtil.isOnUiThread()) { runTakeScreenshot.run(); @@ -753,7 +759,8 @@ private static byte[] takeScreenshotOnUiThread(Activity activity) { public void fetchViewHierarchy(Promise promise) { final @Nullable Activity activity = getCurrentActivity(); - final @Nullable ViewHierarchy viewHierarchy = ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger); + final @Nullable ViewHierarchy viewHierarchy = + ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger); if (viewHierarchy == null) { logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy."); promise.resolve(null); @@ -761,7 +768,8 @@ public void fetchViewHierarchy(Promise promise) { } ISerializer serializer = ScopesAdapter.getInstance().getOptions().getSerializer(); - final @Nullable byte[] bytes = JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); + final @Nullable byte[] bytes = + JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); if (bytes == null) { logger.log(SentryLevel.ERROR, "Could not serialize ViewHierarchy."); promise.resolve(null); @@ -926,39 +934,23 @@ public void setTag(String key, String value) { } public void setAttribute(String key, String value) { - Sentry.configureScope( - scope -> { - // Android SDK doesn't have setAttribute yet, store in "attributes" context - // TODO(alwx): remove once setAttribute is implemented - final Map existingAttributes = new HashMap<>(); - final Object existing = scope.getContexts().get("attributes"); - if (existing instanceof Map) { - existingAttributes.putAll((Map) existing); - } - existingAttributes.put(key, value); - scope.setContexts("attributes", existingAttributes); - }); + // TODO(alwx): This is not implemented in sentry-android yet + /* + * Sentry.configureScope( + * scope -> { + * scope.setAttribute(key, value); + * }); + */ } public void setAttributes(ReadableMap attributes) { - Sentry.configureScope( - scope -> { - // Android SDK doesn't have setAttribute yet, store in "attributes" context - // TODO(alwx): remove once setAttribute is implemented - final Map existingAttributes = new HashMap<>(); - final Object existing = scope.getContexts().get("attributes"); - if (existing instanceof Map) { - existingAttributes.putAll((Map) existing); - } - - final ReadableMapKeySetIterator iterator = attributes.keySetIterator(); - while (iterator.hasNextKey()) { - final String key = iterator.nextKey(); - final String value = attributes.getString(key); - existingAttributes.put(key, value); - } - scope.setContexts("attributes", existingAttributes); - }); + // TODO(alwx): This is not implemented in sentry-android yet + /* + * Sentry.configureScope( + * scope -> { + * scope.setAttributes(attributes); + * }); + */ } public void closeNativeSdk(Promise promise) { @@ -1007,7 +999,8 @@ public void getNewScreenTimeToDisplay(Promise promise) { private String getProfilingTracesDirPath() { if (cacheDirPath == null) { - cacheDirPath = new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); + cacheDirPath = + new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); } File profilingTraceDir = new File(cacheDirPath, "profiling_trace"); profilingTraceDir.mkdirs(); @@ -1020,12 +1013,13 @@ private void initializeAndroidProfiler() { } final String tracesFilesDirPath = getProfilingTracesDirPath(); - androidProfiler = new AndroidProfiler( - tracesFilesDirPath, - (int) SECONDS.toMicros(1) / profilingTracesHz, - new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), - executorService, - logger); + androidProfiler = + new AndroidProfiler( + tracesFilesDirPath, + (int) SECONDS.toMicros(1) / profilingTracesHz, + new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), + executorService, + logger); } public WritableMap startProfiling(boolean platformProfilers) { @@ -1059,8 +1053,9 @@ public WritableMap stopProfiling() { } HermesSamplingProfiler.disable(); - output = File.createTempFile( - "sampling-profiler-trace", ".cpuprofile", reactApplicationContext.getCacheDir()); + output = + File.createTempFile( + "sampling-profiler-trace", ".cpuprofile", reactApplicationContext.getCacheDir()); if (isDebug) { logger.log(SentryLevel.INFO, "Profile saved to: " + output.getAbsolutePath()); } @@ -1070,8 +1065,10 @@ public WritableMap stopProfiling() { if (end != null) { WritableMap androidProfile = new WritableNativeMap(); - byte[] androidProfileBytes = FileUtils.readBytesFromFile(end.traceFile.getPath(), maxTraceFileSize); - String base64AndroidProfile = Base64.encodeToString(androidProfileBytes, NO_WRAP | NO_PADDING); + byte[] androidProfileBytes = + FileUtils.readBytesFromFile(end.traceFile.getPath(), maxTraceFileSize); + String base64AndroidProfile = + Base64.encodeToString(androidProfileBytes, NO_WRAP | NO_PADDING); androidProfile.putString("sampled_profile", base64AndroidProfile); androidProfile.putInt("android_api_level", buildInfo.getSdkInfoVersion()); @@ -1100,8 +1097,8 @@ public WritableMap stopProfiling() { return proguardUuid; } isProguardDebugMetaLoaded = true; - final @Nullable List debugMetaList = new AssetsDebugMetaLoader(this.getReactApplicationContext(), - logger).loadDebugMeta(); + final @Nullable List debugMetaList = + new AssetsDebugMetaLoader(this.getReactApplicationContext(), logger).loadDebugMeta(); if (debugMetaList == null) { return null; } @@ -1119,7 +1116,7 @@ public WritableMap stopProfiling() { } private String readStringFromFile(File path) throws IOException { - try (BufferedReader br = new BufferedReader(new FileReader(path));) { + try (BufferedReader br = new BufferedReader(new FileReader(path)); ) { final StringBuilder text = new StringBuilder(); String line; @@ -1169,8 +1166,8 @@ protected void fetchNativeDeviceContexts( } } - final @NotNull Map serialized = InternalSentrySdk.serializeScope(context, - (SentryAndroidOptions) options, currentScope); + final @NotNull Map serialized = + InternalSentrySdk.serializeScope(context, (SentryAndroidOptions) options, currentScope); final @Nullable Object deviceContext = RNSentryMapConverter.convertToWritable(serialized); promise.resolve(deviceContext); } @@ -1186,8 +1183,9 @@ protected void fetchNativeLogContexts( return; } - Object contextsObj = InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) - .get("contexts"); + Object contextsObj = + InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) + .get("contexts"); if (!(contextsObj instanceof Map)) { promise.resolve(null); @@ -1216,7 +1214,8 @@ protected void fetchNativeLogContexts( } public void fetchNativeSdkInfo(Promise promise) { - final @Nullable SdkVersion sdkVersion = ScopesAdapter.getInstance().getOptions().getSdkVersion(); + final @Nullable SdkVersion sdkVersion = + ScopesAdapter.getInstance().getOptions().getSdkVersion(); if (sdkVersion == null) { promise.resolve(null); } else { @@ -1234,7 +1233,8 @@ public String fetchNativePackageName() { public void getDataFromUri(String uri, Promise promise) { try { Uri contentUri = Uri.parse(uri); - try (InputStream is = getReactApplicationContext().getContentResolver().openInputStream(contentUri)) { + try (InputStream is = + getReactApplicationContext().getContentResolver().openInputStream(contentUri)) { if (is == null) { String msg = "File not found for uri: " + uri; logger.log(SentryLevel.ERROR, msg); diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 29c9969911..3461af87af 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -725,16 +725,18 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys RCT_EXPORT_METHOD(setAttribute : (NSString *)key value : (NSString *)value) { - [SentrySDKWrapper - configureScope:^(SentryScope *_Nonnull scope) { [scope setAttribute:value forKey:key]; }]; + // TODO(alwx): This is not implemented in sentry-cocoa yet + /*[SentrySDKWrapper + configureScope:^(SentryScope *_Nonnull scope) { [scope setAttribute:value forKey:key]; }];*/ } RCT_EXPORT_METHOD(setAttributes : (NSDictionary *)attributes) { - [SentrySDKWrapper configureScope:^(SentryScope *_Nonnull scope) { + // TODO(alwx): This is not implemented in sentry-cocoa yet + /*[SentrySDKWrapper configureScope:^(SentryScope *_Nonnull scope) { [attributes enumerateKeysAndObjectsUsingBlock:^( NSString *key, NSString *value, BOOL *stop) { [scope setAttribute:value forKey:key]; }]; - }]; + }];*/ } RCT_EXPORT_METHOD(crash) { [SentrySDKWrapper crash]; } From c5d6d8979ab99779a59be2259cfd1e4fa586da3a Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 27 Jan 2026 16:15:08 +0100 Subject: [PATCH 8/8] Updated extractScopeAttributes function --- .../core/src/js/integrations/logEnricherIntegration.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/js/integrations/logEnricherIntegration.ts b/packages/core/src/js/integrations/logEnricherIntegration.ts index ba8a6272fd..98b3524e58 100644 --- a/packages/core/src/js/integrations/logEnricherIntegration.ts +++ b/packages/core/src/js/integrations/logEnricherIntegration.ts @@ -113,14 +113,12 @@ function extractScopeAttributes( target: Record, ): void { if (scope && typeof scope.getScopeData === 'function') { - const scopeData = scope.getScopeData(); - const scopeAttrs = scopeData.attributes || {}; - Object.keys(scopeAttrs).forEach((key: string) => { - const value = scopeAttrs[key]; + const scopeAttrs = scope?.getScopeData?.().attributes || {}; + for (const [key, value] of Object.entries(scopeAttrs)) { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { target[key] = value; } - }); + } } }