diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db61a5696..4d9472bc19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Add View Hierarchy to the crashed/errored events ([#2708](https://github.com/getsentry/sentry-react-native/pull/2708)) - Collect modules script for XCode builds supports NODE_BINARY to set path to node executable ([#2805](https://github.com/getsentry/sentry-react-native/pull/2805)) ### Dependencies diff --git a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index a6e33ca7e8..f96c839673 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -23,7 +23,6 @@ import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; -import com.facebook.react.module.annotations.ReactModule; import java.io.BufferedInputStream; import java.io.File; @@ -42,6 +41,7 @@ import io.sentry.DateUtils; import io.sentry.HubAdapter; import io.sentry.ILogger; +import io.sentry.ISerializer; import io.sentry.Integration; import io.sentry.Sentry; import io.sentry.SentryDate; @@ -54,12 +54,14 @@ import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.NdkIntegration; -import io.sentry.android.core.ScreenshotEventProcessor; import io.sentry.android.core.SentryAndroid; +import io.sentry.android.core.ViewHierarchyEventProcessor; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryException; import io.sentry.protocol.SentryPackage; import io.sentry.protocol.User; +import io.sentry.protocol.ViewHierarchy; +import io.sentry.util.JsonSerializationUtils; public class RNSentryModuleImpl { @@ -74,7 +76,6 @@ public class RNSentryModuleImpl { private final PackageInfo packageInfo; private FrameMetricsAggregator frameMetricsAggregator = null; private boolean androidXAvailable; - private ScreenshotEventProcessor screenshotEventProcessor; private static boolean didFetchAppStart; @@ -153,6 +154,9 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { if (rnOptions.hasKey("attachScreenshot")) { options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot")); } + if (rnOptions.hasKey("attachViewHierarchy")) { + options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy")); + } if (rnOptions.hasKey("sendDefaultPii")) { options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii")); } @@ -389,6 +393,35 @@ private static byte[] takeScreenshotOnUiThread(Activity activity) { return bytesWrapper[0]; } + public void fetchViewHierarchy(Promise promise) { + final @Nullable Activity activity = getCurrentActivity(); + final @Nullable ViewHierarchy viewHierarchy = ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger); + if (viewHierarchy == null) { + logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy."); + promise.resolve(null); + return; + } + + ISerializer serializer = HubAdapter.getInstance().getOptions().getSerializer(); + final @Nullable byte[] bytes = JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); + if (bytes == null) { + logger.log(SentryLevel.ERROR, "Could not serialize ViewHierarchy."); + promise.resolve(null); + return; + } + if (bytes.length < 1) { + logger.log(SentryLevel.ERROR, "Got empty bytes array after serializing ViewHierarchy."); + promise.resolve(null); + return; + } + + final WritableNativeArray data = new WritableNativeArray(); + for (final byte b : bytes) { + data.pushInt(b); + } + promise.resolve(data); + } + private static PackageInfo getPackageInfo(Context ctx) { try { return ctx.getPackageManager().getPackageInfo(ctx.getPackageName(), 0); diff --git a/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 8f63ab95c5..40eb787b13 100644 --- a/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -62,6 +62,11 @@ public void captureScreenshot(Promise promise) { this.impl.captureScreenshot(promise); } + @Override + public void fetchViewHierarchy(Promise promise){ + this.impl.fetchViewHierarchy(promise); + } + @Override public void setUser(final ReadableMap user, final ReadableMap otherUserKeys) { this.impl.setUser(user, otherUserKeys); diff --git a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 64002f044a..c1f09c5c4d 100644 --- a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -62,6 +62,11 @@ public void captureScreenshot(Promise promise) { this.impl.captureScreenshot(promise); } + @ReactMethod + public void fetchViewHierarchy(Promise promise){ + this.impl.fetchViewHierarchy(promise); + } + @ReactMethod public void setUser(final ReadableMap user, final ReadableMap otherUserKeys) { this.impl.setUser(user, otherUserKeys); diff --git a/ios/RNSentry.mm b/ios/RNSentry.mm index 50cdd18339..80db9de992 100644 --- a/ios/RNSentry.mm +++ b/ios/RNSentry.mm @@ -337,6 +337,20 @@ - (void)setEventEnvironmentTag:(SentryEvent *)event resolve(screenshotsArray); } +RCT_EXPORT_METHOD(fetchViewHierarchy: (RCTPromiseResolveBlock)resolve + rejecter: (RCTPromiseRejectBlock)reject) +{ + NSData * rawViewHierarchy = [PrivateSentrySDKOnly captureViewHierarchy]; + + NSMutableArray *viewHierarchy = [NSMutableArray arrayWithCapacity:rawViewHierarchy.length]; + const char *bytes = (char*) [rawViewHierarchy bytes]; + for (int i = 0; i < [rawViewHierarchy length]; i++) { + [viewHierarchy addObject:[[NSNumber alloc] initWithChar:bytes[i]]]; + } + + resolve(viewHierarchy); +} + RCT_EXPORT_METHOD(setUser:(NSDictionary *)userKeys otherUserKeys:(NSDictionary *)userDataKeys ) diff --git a/sample-new-architecture/src/App.tsx b/sample-new-architecture/src/App.tsx index eee46f1ff6..28d9ad60e7 100644 --- a/sample-new-architecture/src/App.tsx +++ b/sample-new-architecture/src/App.tsx @@ -63,6 +63,7 @@ Sentry.init({ // otherwise they will not work. // release: 'myapp@1.2.3+1', // dist: `1`, + attachViewHierarchy: true, }); const Stack = createStackNavigator(); diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 3524bf30c1..501d92f926 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -79,6 +79,8 @@ Sentry.init({ attachStacktrace: true, // Attach screenshots to events. attachScreenshot: true, + // Attach view hierarchy to events. + attachViewHierarchy: true, }); const Stack = createStackNavigator(); diff --git a/src/js/NativeRNSentry.ts b/src/js/NativeRNSentry.ts index 7750a0f263..c4380faf5f 100644 --- a/src/js/NativeRNSentry.ts +++ b/src/js/NativeRNSentry.ts @@ -33,6 +33,7 @@ export interface Spec extends TurboModule { setTag(key: string, value: string): void; enableNativeFramesTracking(): void; fetchModules(): Promise; + fetchViewHierarchy(): Promise; } export type NativeAppStartResponse = { diff --git a/src/js/integrations/viewhierarchy.ts b/src/js/integrations/viewhierarchy.ts new file mode 100644 index 0000000000..02539c5d58 --- /dev/null +++ b/src/js/integrations/viewhierarchy.ts @@ -0,0 +1,54 @@ +import type { Event, EventHint, EventProcessor, Integration } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import { NATIVE } from '../wrapper'; + +/** Adds ViewHierarchy to error events */ +export class ViewHierarchy implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'ViewHierarchy'; + + private static _fileName: string = 'view-hierarchy.json'; + private static _contentType: string = 'application/json'; + private static _attachmentType: string = 'event.view_hierarchy'; + + /** + * @inheritDoc + */ + public name: string = ViewHierarchy.id; + + /** + * @inheritDoc + */ + public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { + addGlobalEventProcessor(async (event: Event, hint: EventHint) => { + const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + if (!hasException) { + return event; + } + + let viewHierarchy: Uint8Array | null = null; + try { + viewHierarchy = await NATIVE.fetchViewHierarchy() + } catch (e) { + logger.error('Failed to get view hierarchy from native.', e); + } + + if (viewHierarchy) { + hint.attachments = [ + { + filename: ViewHierarchy._fileName, + contentType: ViewHierarchy._contentType, + attachmentType: ViewHierarchy._attachmentType, + data: viewHierarchy, + }, + ...(hint?.attachments || []), + ]; + } + + return event; + }); + } +} diff --git a/src/js/options.ts b/src/js/options.ts index e46178fc87..795fcfd6bd 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -134,6 +134,13 @@ export interface BaseReactNativeOptions { * @default false */ attachScreenshot?: boolean; + + /** + * When enabled Sentry includes the current view hierarchy in the error attachments. + * + * @default false + */ + attachViewHierarchy?: boolean; } export interface ReactNativeTransportOptions extends BrowserTransportOptions { diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index 3a64d5219c..41234e9110 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -1,4 +1,4 @@ -import type { Scope} from '@sentry/core'; +import type { Scope } from '@sentry/core'; import { getIntegrationsToSetup, Hub, initAndBind, makeMain, setExtra } from '@sentry/core'; import { RewriteFrames } from '@sentry/integrations'; import { @@ -23,6 +23,7 @@ import { SdkInfo, } from './integrations'; import { Screenshot } from './integrations/screenshot'; +import { ViewHierarchy } from './integrations/viewhierarchy'; import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options'; import { ReactNativeScope } from './scope'; import { TouchEventBoundary } from './touchevents'; @@ -140,6 +141,9 @@ export function init(passedOptions: ReactNativeOptions): void { if (options.attachScreenshot) { defaultIntegrations.push(new Screenshot()); } + if (options.attachViewHierarchy) { + defaultIntegrations.push(new ViewHierarchy()); + } } options.integrations = getIntegrationsToSetup({ @@ -247,9 +251,9 @@ export async function close(): Promise { /** * Captures user feedback and sends it to Sentry. */ - export function captureUserFeedback(feedback: UserFeedback): void { +export function captureUserFeedback(feedback: UserFeedback): void { getCurrentHub().getClient()?.captureUserFeedback(feedback); - } +} /** * Creates a new scope with and executes the given operation within. @@ -279,7 +283,7 @@ export function withScope(callback: (scope: Scope) => void): ReturnType void): ReturnType { +export function configureScope(callback: (scope: Scope) => void): ReturnType { const safeCallback = (scope: Scope): void => { try { callback(scope); diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index 2aebde9509..b4c90e8238 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -80,6 +80,7 @@ interface SentryNativeWrapper { nativeCrash(): void; fetchModules(): Promise | null>; + fetchViewHierarchy(): PromiseLike; } /** @@ -491,6 +492,18 @@ export const NATIVE: SentryNativeWrapper = { } }, + async fetchViewHierarchy(): Promise { + if (!this.enableNative) { + throw this._DisabledNativeError; + } + if (!this._isModuleLoaded(RNSentry)) { + throw this._NativeClientError; + } + + const raw = await RNSentry.fetchViewHierarchy(); + return raw ? new Uint8Array(raw) : null; + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. diff --git a/test/integrations/viewhierarchy.test.ts b/test/integrations/viewhierarchy.test.ts new file mode 100644 index 0000000000..9390b0bf87 --- /dev/null +++ b/test/integrations/viewhierarchy.test.ts @@ -0,0 +1,118 @@ +import type { Event, EventHint } from '@sentry/types'; + +import { ViewHierarchy } from '../../src/js/integrations/viewhierarchy'; +import { NATIVE } from '../../src/js/wrapper'; + +jest.mock('../../src/js/wrapper'); + +describe('ViewHierarchy', () => { + let integration: ViewHierarchy; + let mockEvent: Event; + + beforeEach(() => { + integration = new ViewHierarchy(); + mockEvent = { + exception: { + values: [ + { + value: 'Mock Error Event', + }, + ], + }, + }; + }); + + it('integration event processor does not throw on native error', async () => { + (NATIVE.fetchViewHierarchy as jest.Mock).mockImplementation(() => { throw new Error('Test Error') }); + const mockHint: EventHint = {}; + await executeIntegrationFor(mockEvent, mockHint); + expect(mockHint).toEqual({}); + }); + + it('returns unchanged event', async () => { + (NATIVE.fetchViewHierarchy as jest.Mock).mockImplementation( + (() => Promise.resolve(new Uint8Array([]))) + ); + await executeIntegrationFor(mockEvent); + + expect(mockEvent).toEqual({ + exception: { + values: [ + { + value: 'Mock Error Event', + }, + ], + }, + }); + }); + + it('adds view hierarchy attachment in event hint', async () => { + (NATIVE.fetchViewHierarchy as jest.Mock).mockImplementation( + (() => Promise.resolve((new Uint8Array([ 1, 2, 3 ])))) + ); + const mockHint: EventHint = {}; + await executeIntegrationFor(mockEvent, mockHint); + + expect(mockHint).toEqual({ + attachments: [{ + filename: 'view-hierarchy.json', + contentType: 'application/json', + attachmentType: 'event.view_hierarchy', + data: new Uint8Array([ 1, 2, 3 ]), + }], + }); + }); + + it('does not modify existing event hint attachments', async () => { + (NATIVE.fetchViewHierarchy as jest.Mock).mockImplementation( + (() => Promise.resolve((new Uint8Array([1, 2, 3])))) + ); + const mockHint: EventHint = { + attachments: [{ + filename: 'test-attachment.txt', + contentType: 'text/plain', + data: new Uint8Array([4, 5, 6]), + }], + }; + await executeIntegrationFor(mockEvent, mockHint); + + expect(mockHint).toEqual({ + attachments: [ + { + filename: 'view-hierarchy.json', + contentType: 'application/json', + attachmentType: 'event.view_hierarchy', + data: new Uint8Array([1, 2, 3]), + }, + { + filename: 'test-attachment.txt', + contentType: 'text/plain', + data: new Uint8Array([4, 5, 6]), + }, + ], + }); + }); + + it('does not create empty view hierarchy attachment in event hint', async () => { + (NATIVE.fetchViewHierarchy as jest.Mock).mockImplementation( + (() => Promise.resolve(null)) + ); + const mockHint: EventHint = {}; + await executeIntegrationFor(mockEvent, mockHint); + + expect(mockHint).toEqual({}); + }); + + function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint = {}): Promise { + return new Promise((resolve, reject) => { + integration.setupOnce(async (eventProcessor) => { + try { + const processedEvent = await eventProcessor(mockedEvent, mockedHint); + resolve(processedEvent); + } catch (e) { + reject(e); + } + }); + }); + } +}); diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 079b4c414c..1072ee1506 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -342,6 +342,26 @@ describe('Tests the SDK functionality', () => { expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Screenshot' })])); }); + it('no view hierarchy integration by default', () => { + init({}); + + const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; + const actualIntegrations = actualOptions.integrations; + + expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'ViewHierarchy' })])); + }); + + it('adds view hierarchy integration', () => { + init({ + attachViewHierarchy: true, + }); + + const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; + const actualIntegrations = actualOptions.integrations; + + expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'ViewHierarchy' })])); + }); + it('no default integrations', () => { init({ defaultIntegrations: false,