diff --git a/CHANGELOG.md b/CHANGELOG.md index 081d694d7e..62f77e5bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Screenshots ([#2610](https://github.com/getsentry/sentry-react-native/pull/2610)) + ## 4.10.1 ### Fixes diff --git a/android/src/main/java/io/sentry/react/RNSentryModule.java b/android/src/main/java/io/sentry/react/RNSentryModule.java index 2d86b80e85..b241d89efc 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModule.java +++ b/android/src/main/java/io/sentry/react/RNSentryModule.java @@ -1,5 +1,7 @@ package io.sentry.react; +import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; + import android.app.Activity; import android.content.Context; import android.content.pm.PackageInfo; @@ -17,7 +19,10 @@ import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.WritableArray; 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; @@ -31,11 +36,10 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.logging.Level; -import java.util.logging.Logger; import io.sentry.Breadcrumb; import io.sentry.HubAdapter; +import io.sentry.ILogger; import io.sentry.Integration; import io.sentry.Sentry; import io.sentry.SentryEvent; @@ -43,8 +47,12 @@ import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AnrIntegration; import io.sentry.android.core.AppStartState; +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.AndroidLogger; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryException; import io.sentry.protocol.SentryPackage; @@ -55,13 +63,15 @@ public class RNSentryModule extends ReactContextBaseJavaModule { public static final String NAME = "RNSentry"; - private static final Logger logger = Logger.getLogger("react-native-sentry"); + private static final ILogger logger = new AndroidLogger(NAME); + private static final BuildInfoProvider buildInfo = new BuildInfoProvider(logger); private static final String modulesPath = "modules.json"; private static final Charset UTF_8 = Charset.forName("UTF-8"); private final PackageInfo packageInfo; private FrameMetricsAggregator frameMetricsAggregator = null; private boolean androidXAvailable; + private ScreenshotEventProcessor screenshotEventProcessor; private static boolean didFetchAppStart; @@ -86,11 +96,10 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { SentryAndroid.init(this.getReactApplicationContext(), options -> { if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) { options.setDebug(true); - logger.setLevel(Level.INFO); } if (rnOptions.hasKey("dsn") && rnOptions.getString("dsn") != null) { String dsn = rnOptions.getString("dsn"); - logger.info(String.format("Starting with DSN: '%s'", dsn)); + logger.log(SentryLevel.INFO, String.format("Starting with DSN: '%s'", dsn)); options.setDsn(dsn); } else { // SentryAndroid needs an empty string fallback for the dsn. @@ -134,6 +143,9 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { // by default we hide. options.setAttachThreads(rnOptions.getBoolean("attachThreads")); } + if (rnOptions.hasKey("attachScreenshot")) { + options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot")); + } if (rnOptions.hasKey("sendDefaultPii")) { options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii")); } @@ -169,8 +181,13 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { } } } + logger.log(SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations())); - logger.info(String.format("Native Integrations '%s'", options.getIntegrations())); + final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance(); + final Activity currentActivity = getCurrentActivity(); + if (currentActivity != null) { + currentActivityHolder.setActivity(currentActivity); + } }); promise.resolve(true); @@ -195,7 +212,7 @@ public void fetchModules(Promise promise) { } catch (FileNotFoundException e) { promise.resolve(null); } catch (Throwable e) { - logger.warning("Fetching JS Modules failed."); + logger.log(SentryLevel.WARNING, "Fetching JS Modules failed."); promise.resolve(null); } } @@ -216,10 +233,10 @@ public void fetchNativeAppStart(Promise promise) { final Boolean isColdStart = appStartInstance.isColdStart(); if (appStartTime == null) { - logger.warning("App start won't be sent due to missing appStartTime."); + logger.log(SentryLevel.WARNING, "App start won't be sent due to missing appStartTime."); promise.resolve(null); } else if (isColdStart == null) { - logger.warning("App start won't be sent due to missing isColdStart."); + logger.log(SentryLevel.WARNING, "App start won't be sent due to missing isColdStart."); promise.resolve(null); } else { final double appStartTimestamp = (double) appStartTime.getTime(); @@ -285,7 +302,7 @@ public void fetchNativeFrames(Promise promise) { promise.resolve(map); } catch (Throwable ignored) { - logger.warning("Error fetching native frames."); + logger.log(SentryLevel.WARNING, "Error fetching native frames."); promise.resolve(null); } } @@ -302,7 +319,7 @@ public void captureEnvelope(ReadableArray rawBytes, ReadableMap options, Promise final String outboxPath = HubAdapter.getInstance().getOptions().getOutboxPath(); if (outboxPath == null) { - logger.severe( + logger.log(SentryLevel.ERROR, "Error retrieving outboxPath. Envelope will not be sent. Is the Android SDK initialized?"); } else { File installation = new File(outboxPath, UUID.randomUUID().toString()); @@ -311,16 +328,47 @@ public void captureEnvelope(ReadableArray rawBytes, ReadableMap options, Promise } } } catch (Throwable ignored) { - logger.severe("Error while writing envelope to outbox."); + logger.log(SentryLevel.ERROR, "Error while writing envelope to outbox."); } promise.resolve(true); } + @ReactMethod + public void captureScreenshot(Promise promise) { + + final Activity activity = getCurrentActivity(); + if (activity == null) { + logger.log(SentryLevel.WARNING, "CurrentActivity is null, can't capture screenshot."); + promise.resolve(null); + return; + } + + final byte[] raw = takeScreenshot(activity, logger, buildInfo); + if (raw == null) { + logger.log(SentryLevel.WARNING, "Screenshot is null, screen was not captured."); + promise.resolve(null); + return; + } + + final WritableNativeArray data = new WritableNativeArray(); + for (final byte b : raw) { + data.pushInt(b); + } + final WritableMap screenshot = new WritableNativeMap(); + screenshot.putString("contentType", "image/png"); + screenshot.putArray("data", data); + screenshot.putString("filename", "screenshot.png"); + + final WritableArray screenshotsArray = new WritableNativeArray(); + screenshotsArray.pushMap(screenshot); + promise.resolve(screenshotsArray); + } + private static PackageInfo getPackageInfo(Context ctx) { try { return ctx.getPackageManager().getPackageInfo(ctx.getPackageName(), 0); } catch (PackageManager.NameNotFoundException e) { - logger.warning("Error getting package info."); + logger.log(SentryLevel.WARNING, "Error getting package info."); return null; } } @@ -483,17 +531,17 @@ public void enableNativeFramesTracking() { try { frameMetricsAggregator.add(currentActivity); - logger.info("FrameMetricsAggregator installed."); + logger.log(SentryLevel.INFO, "FrameMetricsAggregator installed."); } catch (Throwable ignored) { // throws ConcurrentModification when calling addOnFrameMetricsAvailableListener // this is a best effort since we can't reproduce it - logger.severe("Error adding Activity to frameMetricsAggregator."); + logger.log(SentryLevel.ERROR, "Error adding Activity to frameMetricsAggregator."); } } else { - logger.info("currentActivity isn't available."); + logger.log(SentryLevel.INFO, "currentActivity isn't available."); } } else { - logger.warning("androidx.core' isn't available as a dependency."); + logger.log(SentryLevel.WARNING, "androidx.core' isn't available as a dependency."); } } diff --git a/ios/RNSentry.m b/ios/RNSentry.m index 59193d4316..9c9eed2110 100644 --- a/ios/RNSentry.m +++ b/ios/RNSentry.m @@ -302,6 +302,35 @@ - (void)setEventEnvironmentTag:(SentryEvent *)event resolve(@YES); } +RCT_EXPORT_METHOD(captureScreenshot: (RCTPromiseResolveBlock)resolve + rejecter: (RCTPromiseRejectBlock)reject) +{ + NSArray* rawScreenshots = [PrivateSentrySDKOnly captureScreenshots]; + NSMutableArray *screenshotsArray = [NSMutableArray arrayWithCapacity:[rawScreenshots count]]; + + int counter = 1; + for (NSData* raw in rawScreenshots) { + NSMutableArray *screenshot = [NSMutableArray arrayWithCapacity:raw.length]; + const char *bytes = [raw bytes]; + for (int i = 0; i < [raw length]; i++) { + [screenshot addObject:[[NSNumber alloc] initWithChar:bytes[i]]]; + } + + NSString* filename = @"screenshot.png"; + if (counter > 1) { + filename = [NSString stringWithFormat:@"screenshot-%d.png", counter]; + } + [screenshotsArray addObject:@{ + @"data": screenshot, + @"contentType": @"image/png", + @"filename": filename, + }]; + counter++; + } + + resolve(screenshotsArray); +} + RCT_EXPORT_METHOD(setUser:(NSDictionary *)userKeys otherUserKeys:(NSDictionary *)userDataKeys ) diff --git a/sample/src/App.tsx b/sample/src/App.tsx index a387fd6406..ddf70521d2 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -62,6 +62,8 @@ Sentry.init({ // release: 'myapp@1.2.3+1', // dist: `1`, attachStacktrace: true, + // Attach screenshots to events. + attachScreenshot: true, }); const Stack = createStackNavigator(); diff --git a/src/js/client.ts b/src/js/client.ts index 6c9813567b..ebf79ed0ed 100644 --- a/src/js/client.ts +++ b/src/js/client.ts @@ -16,6 +16,7 @@ import { dateTimestampInSeconds, logger, SentryError } from '@sentry/utils'; // @ts-ignore LogBox introduced in RN 0.63 import { Alert, LogBox, YellowBox } from 'react-native'; +import { Screenshot } from './integrations/screenshot'; import { defaultSdkInfo } from './integrations/sdkinfo'; import { ReactNativeClientOptions, ReactNativeTransportOptions } from './options'; import { makeReactNativeTransport } from './transports/native'; @@ -81,8 +82,9 @@ export class ReactNativeClient extends BaseClient { /** * @inheritDoc */ - public eventFromException(_exception: unknown, _hint?: EventHint): PromiseLike { - return this._browserClient.eventFromException(_exception, _hint); + public eventFromException(exception: unknown, hint: EventHint = {}): PromiseLike { + return Screenshot.attachScreenshotToEventHint(hint, this._options) + .then(enrichedHint => this._browserClient.eventFromException(exception, enrichedHint)); } /** diff --git a/src/js/definitions.ts b/src/js/definitions.ts index a0558fdb29..4058148b71 100644 --- a/src/js/definitions.ts +++ b/src/js/definitions.ts @@ -24,6 +24,12 @@ export type NativeDeviceContextsResponse = { [key: string]: Record; }; +export interface NativeScreenshot { + data: number[]; + contentType: string; + filename: string; +} + interface SerializedObject { [key: string]: string; } @@ -37,6 +43,7 @@ export interface SentryNativeBridgeModule { store: boolean, }, ): PromiseLike; + captureScreenshot(): PromiseLike; clearBreadcrumbs(): void; crash(): void; closeNativeSdk(): PromiseLike; diff --git a/src/js/integrations/screenshot.ts b/src/js/integrations/screenshot.ts new file mode 100644 index 0000000000..f7db742abe --- /dev/null +++ b/src/js/integrations/screenshot.ts @@ -0,0 +1,46 @@ +import { EventHint, Integration } from '@sentry/types'; +import { resolvedSyncPromise } from '@sentry/utils'; + +import { NATIVE } from '../wrapper'; + +/** Adds screenshots to error events */ +export class Screenshot implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Screenshot'; + + /** + * @inheritDoc + */ + public name: string = Screenshot.id; + + /** + * If enabled attaches a screenshot to the event hint. + */ + public static attachScreenshotToEventHint( + hint: EventHint, + { attachScreenshot }: { attachScreenshot?: boolean }, + ): PromiseLike { + if (!attachScreenshot) { + return resolvedSyncPromise(hint); + } + + return NATIVE.captureScreenshot() + .then((screenshots) => { + if (screenshots !== null && screenshots.length > 0) { + hint.attachments = [ + ...screenshots, + ...(hint?.attachments || []), + ]; + } + return hint; + }); + } + + /** + * @inheritDoc + */ + // eslint-disable-next-line @typescript-eslint/no-empty-function + public setupOnce(): void {} +} diff --git a/src/js/options.ts b/src/js/options.ts index 8a4d2e97ce..c66c1ea169 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -126,6 +126,13 @@ export interface BaseReactNativeOptions { * The max queue size for capping the number of envelopes waiting to be sent by Transport. */ maxQueueSize?: number; + + /** + * When enabled and a user experiences an error, Sentry provides the ability to take a screenshot and include it as an attachment. + * + * @default false + */ + attachScreenshot?: boolean; } export interface ReactNativeTransportOptions extends BrowserTransportOptions { diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index a44c3daf6d..45ffba150e 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -19,6 +19,7 @@ import { Release, SdkInfo, } from './integrations'; +import { Screenshot } from './integrations/screenshot'; import { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options'; import { ReactNativeScope } from './scope'; import { TouchEventBoundary } from './touchevents'; @@ -132,6 +133,9 @@ export function init(passedOptions: ReactNativeOptions): void { defaultIntegrations.push(new ReactNativeTracing()); } } + if (options.attachScreenshot) { + defaultIntegrations.push(new Screenshot()); + } } options.integrations = getIntegrationsToSetup({ diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index 3861054caa..5abd733ae6 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -17,6 +17,7 @@ import { NativeDeviceContextsResponse, NativeFramesResponse, NativeReleaseResponse, + NativeScreenshot, SentryNativeBridgeModule, } from './definitions'; import { isHardCrash } from './misc'; @@ -26,6 +27,12 @@ import { utf8ToBytes } from './vendor'; const RNSentry = NativeModules.RNSentry as SentryNativeBridgeModule | undefined; +export interface Screenshot { + data: Uint8Array; + contentType: string; + filename: string; +} + interface SentryNativeWrapper { enableNative: boolean; nativeIsReady: boolean; @@ -49,6 +56,7 @@ interface SentryNativeWrapper { closeNativeSdk(): PromiseLike; sendEnvelope(envelope: Envelope): Promise; + captureScreenshot(): Promise; fetchNativeRelease(): PromiseLike; fetchNativeDeviceContexts(): PromiseLike; @@ -105,19 +113,25 @@ export const NATIVE: SentryNativeWrapper = { const [envelopeHeader, envelopeItems] = envelope; const headerString = JSON.stringify(envelopeHeader); - const envelopeBytes: number[] = utf8ToBytes(headerString); + let envelopeBytes: number[] = utf8ToBytes(headerString); envelopeBytes.push(EOL); let hardCrashed: boolean = false; for (const rawItem of envelopeItems) { const [itemHeader, itemPayload] = this._processItem(rawItem); + let bytesContentType: string; let bytesPayload: number[] = []; if (typeof itemPayload === 'string') { + bytesContentType = 'text/plain'; bytesPayload = utf8ToBytes(itemPayload); } else if (itemPayload instanceof Uint8Array) { + bytesContentType = typeof itemHeader.content_type === 'string' + ? itemHeader.content_type + : 'application/octet-stream'; bytesPayload = [...itemPayload]; } else { + bytesContentType = 'application/json'; bytesPayload = utf8ToBytes(JSON.stringify(itemPayload)); if (!hardCrashed) { hardCrashed = isHardCrash(itemPayload); @@ -125,13 +139,13 @@ export const NATIVE: SentryNativeWrapper = { } // Content type is not inside BaseEnvelopeItemHeaders. - (itemHeader as BaseEnvelopeItemHeaders).content_type = 'application/json'; + (itemHeader as BaseEnvelopeItemHeaders).content_type = bytesContentType; (itemHeader as BaseEnvelopeItemHeaders).length = bytesPayload.length; const serializedItemHeader = JSON.stringify(itemHeader); envelopeBytes.push(...utf8ToBytes(serializedItemHeader)); envelopeBytes.push(EOL); - bytesPayload.forEach(byte => envelopeBytes.push(byte)); + envelopeBytes = envelopeBytes.concat(bytesPayload); envelopeBytes.push(EOL); } @@ -451,6 +465,26 @@ export const NATIVE: SentryNativeWrapper = { return this.enableNative && this._isModuleLoaded(RNSentry); }, + async captureScreenshot(): Promise { + if (!this.enableNative) { + throw this._DisabledNativeError; + } + if (!this._isModuleLoaded(RNSentry)) { + throw this._NativeClientError; + } + + try { + const raw = await RNSentry.captureScreenshot(); + return raw.map((item: NativeScreenshot) => ({ + ...item, + data: new Uint8Array(item.data), + })); + } catch (e) { + logger.warn('Failed to capture screenshot', e); + return 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/client.test.ts b/test/client.test.ts index 0896d8e587..ced3a2755f 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -27,6 +27,7 @@ interface MockedReactNative { initNativeSdk: jest.Mock; crash: jest.Mock; captureEnvelope: jest.Mock; + captureScreenshot: jest.Mock; }; }; Platform: { @@ -48,6 +49,7 @@ jest.mock( initNativeSdk: jest.fn(() => Promise.resolve(true)), crash: jest.fn(), captureEnvelope: jest.fn(), + captureScreenshot: jest.fn().mockResolvedValue(null), }, }, Platform: { @@ -284,12 +286,12 @@ describe('Tests ReactNativeClient', () => { describe('event data enhancement', () => { test('event contains sdk default information', async () => { - const mockedSend = jest.fn, [Envelope]>(); + const mockedSend = jest.fn, [Envelope]>().mockResolvedValue(undefined); const mockedTransport = (): Transport => ({ send: mockedSend, flush: jest.fn().mockResolvedValue(true), }); - const client = new ReactNativeClient( { + const client = new ReactNativeClient({ ...DEFAULT_OPTIONS, dsn: EXAMPLE_DSN, transport: mockedTransport, diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 06fe51b53b..c154aff4e7 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -293,6 +293,26 @@ describe('Tests the SDK functionality', () => { expect(actualIntegrations).toEqual([mockDefaultIntegration]); }); + it('no screenshot 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: 'Screenshot' })])); + }); + + it('adds screenshot integration', () => { + init({ + attachScreenshot: true, + }); + + const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; + const actualIntegrations = actualOptions.integrations; + + expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Screenshot' })])); + }); + it('no default integrations', () => { init({ defaultIntegrations: false,