diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b82a914f5..75657e8d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- build(ios): Bump sentry-cocoa to 7.2.0-beta.9 #1704 +- build(android): Bump sentry-android to 5.1.0-beta.9 #1704 +- feat: Add app start measurements to the first transaction #1704 +- feat: Create an initial initial ui.load transaction by default #1704 +- feat: Add `enableAutoPerformanceTracking` flag that enables auto performance when tracing is enabled #1704 + ## 2.7.0-beta.1 - feat: Track stalls in the JavaScript event loop as measurements #1542 diff --git a/RNSentry.podspec b/RNSentry.podspec index ba193d4068..4901343c8e 100644 --- a/RNSentry.podspec +++ b/RNSentry.podspec @@ -17,7 +17,7 @@ Pod::Spec.new do |s| s.preserve_paths = '*.js' s.dependency 'React-Core' - s.dependency 'Sentry', '7.1.4' + s.dependency 'Sentry', '7.2.0-beta.9' s.source_files = 'ios/RNSentry.{h,m}' s.public_header_files = 'ios/RNSentry.h' diff --git a/android/build.gradle b/android/build.gradle index 6d40645dd7..104e58a221 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,5 +26,5 @@ android { dependencies { implementation 'com.facebook.react:react-native:+' - api 'io.sentry:sentry-android:5.1.0-beta.2' + api 'io.sentry:sentry-android:5.1.0-beta.9' } diff --git a/android/src/main/java/io/sentry/react/RNSentryModule.java b/android/src/main/java/io/sentry/react/RNSentryModule.java index 7de70aeb7c..e78716d970 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModule.java +++ b/android/src/main/java/io/sentry/react/RNSentryModule.java @@ -18,7 +18,7 @@ import java.io.FileOutputStream; import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; -import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -27,12 +27,14 @@ import java.util.logging.Logger; import io.sentry.android.core.AnrIntegration; +import io.sentry.android.core.AppStartState; import io.sentry.android.core.NdkIntegration; import io.sentry.android.core.SentryAndroid; -import io.sentry.Sentry; import io.sentry.Breadcrumb; +import io.sentry.DateUtils; import io.sentry.HubAdapter; import io.sentry.Integration; +import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.protocol.SdkVersion; @@ -47,6 +49,7 @@ public class RNSentryModule extends ReactContextBaseJavaModule { final static Logger logger = Logger.getLogger("react-native-sentry"); private static PackageInfo packageInfo; + private static boolean didFetchAppStart = false; public RNSentryModule(ReactApplicationContext reactContext) { super(reactContext); @@ -180,6 +183,29 @@ public void fetchNativeRelease(Promise promise) { promise.resolve(release); } + @ReactMethod + public void fetchNativeAppStart(Promise promise) { + final AppStartState appStartInstance = AppStartState.getInstance(); + final Date appStartTime = appStartInstance.getAppStartTime(); + + if (appStartTime == null) { + promise.resolve(null); + } else { + final double appStartTimestamp = (double) appStartTime.getTime(); + + WritableMap appStart = Arguments.createMap(); + + appStart.putDouble("appStartTime", appStartTimestamp); + appStart.putBoolean("isColdStart", appStartInstance.isColdStart()); + appStart.putBoolean("didFetchAppStart", RNSentryModule.didFetchAppStart); + + promise.resolve(appStart); + } + // This is always set to true, as we would only allow an app start fetch to only happen once + // in the case of a JS bundle reload, we do not want it to be instrumented again. + RNSentryModule.didFetchAppStart = true; + } + @ReactMethod public void captureEnvelope(String envelope, Promise promise) { try { diff --git a/ios/RNSentry.m b/ios/RNSentry.m index 317363d5ac..271ee940b8 100644 --- a/ios/RNSentry.m +++ b/ios/RNSentry.m @@ -16,6 +16,8 @@ + (void)storeEnvelope:(SentryEnvelope *)envelope; @end +static bool didFetchAppStart; + @implementation RNSentry { bool sentHybridSdkDidBecomeActive; } @@ -40,6 +42,8 @@ + (BOOL)requiresMainQueueSetup { resolve:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { + PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = true; + NSError *error = nil; SentryBeforeSendEventCallback beforeSend = ^SentryEvent*(SentryEvent *event) { @@ -80,6 +84,9 @@ + (BOOL)requiresMainQueueSetup { sentHybridSdkDidBecomeActive = true; } + + + resolve(@YES); } @@ -102,6 +109,30 @@ + (BOOL)requiresMainQueueSetup { }]; } +RCT_EXPORT_METHOD(fetchNativeAppStart:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + + SentryAppStartMeasurement *appStartMeasurement = PrivateSentrySDKOnly.appStartMeasurement; + + if (appStartMeasurement == nil) { + resolve(nil); + } else { + BOOL isColdStart = appStartMeasurement.type == SentryAppStartTypeCold; + + resolve(@{ + @"isColdStart": [NSNumber numberWithBool:isColdStart], + @"appStartTime": [NSNumber numberWithDouble:(appStartMeasurement.appStartTimestamp.timeIntervalSince1970 * 1000)], + @"didFetchAppStart": [NSNumber numberWithBool:didFetchAppStart], + }); + + } + + // This is always set to true, as we would only allow an app start fetch to only happen once + // in the case of a JS bundle reload, we do not want it to be instrumented again. + didFetchAppStart = true; +} + RCT_EXPORT_METHOD(fetchNativeRelease:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 30b9eae50a..3ff246f63f 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -41,7 +41,7 @@ Sentry.init({ maxBreadcrumbs: 150, // Extend from the default 100 breadcrumbs. integrations: [ new Sentry.ReactNativeTracing({ - idleTimeout: 5000, + idleTimeout: 5000, // This is the default timeout routingInstrumentation: reactNavigationV5Instrumentation, tracingOrigins: ['localhost', /^\//, /^https:\/\//], beforeNavigate: (context: Sentry.ReactNavigationTransactionContext) => { @@ -71,27 +71,35 @@ const App = () => { const navigation = React.useRef(); return ( - - { - reactNavigationV5Instrumentation.registerNavigationContainer( - navigation, - ); - }}> - - - - - - - - - - + + + { + reactNavigationV5Instrumentation.registerNavigationContainer( + navigation, + ); + }}> + + + + + + + + + + + ); }; diff --git a/src/js/definitions.ts b/src/js/definitions.ts index 82af223bad..bfb8615756 100644 --- a/src/js/definitions.ts +++ b/src/js/definitions.ts @@ -2,6 +2,12 @@ import { Breadcrumb } from "@sentry/types"; import { ReactNativeOptions } from "./options"; +export type NativeAppStartResponse = { + isColdStart: boolean; + appStartTime: number; + didFetchAppStart: boolean; +}; + export type NativeReleaseResponse = { build: string; id: string; @@ -17,6 +23,8 @@ interface SerializedObject { } export interface SentryNativeBridgeModule { + nativeClientAvailable: boolean; + addBreadcrumb(breadcrumb: Breadcrumb): void; captureEnvelope( payload: @@ -35,6 +43,7 @@ export interface SentryNativeBridgeModule { version: string; }>; fetchNativeDeviceContexts(): PromiseLike; + fetchNativeAppStart(): PromiseLike; getStringBytesLength(str: string): Promise; initNativeSdk(options: ReactNativeOptions): Promise; setUser( diff --git a/src/js/options.ts b/src/js/options.ts index de7bfecbbf..149ba8070e 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -68,4 +68,7 @@ export interface ReactNativeOptions extends BrowserOptions { /** Enable JS event loop stall tracking. Enabled by default. */ enableStallTracking?: boolean; + + /** Enable auto performance tracking by default. */ + enableAutoPerformanceTracking?: boolean; } diff --git a/src/js/sdk.ts b/src/js/sdk.ts index 79dbb1d4ce..ad89d719df 100644 --- a/src/js/sdk.ts +++ b/src/js/sdk.ts @@ -15,6 +15,7 @@ import { } from "./integrations"; import { ReactNativeOptions } from "./options"; import { ReactNativeScope } from "./scope"; +import { ReactNativeTracing } from "./tracing"; const IGNORED_DEFAULT_INTEGRATIONS = [ "GlobalHandlers", // We will use the react-native internal handlers @@ -26,6 +27,7 @@ const DEFAULT_OPTIONS: ReactNativeOptions = { enableNativeNagger: true, autoInitializeNativeSdk: true, enableStallTracking: true, + enableAutoPerformanceTracking: true, }; /** @@ -84,8 +86,14 @@ export function init(passedOptions: ReactNativeOptions): void { if (options.enableNative) { options.defaultIntegrations.push(new DeviceContext()); } - if (tracingEnabled && options.enableStallTracking) { - options.defaultIntegrations.push(new StallTracking()); + if (tracingEnabled) { + if (options.enableAutoPerformanceTracking) { + options.defaultIntegrations.push(new ReactNativeTracing()); + + if (options.enableStallTracking) { + options.defaultIntegrations.push(new StallTracking()); + } + } } } diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 7f352e4ff3..0d85e6839b 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -1,6 +1,8 @@ +/* eslint-disable max-lines */ import { Hub } from "@sentry/hub"; import { defaultRequestInstrumentationOptions, + IdleTransaction, registerRequestInstrumentation, RequestInstrumentationOptions, startIdleTransaction, @@ -13,9 +15,11 @@ import { } from "@sentry/types"; import { logger } from "@sentry/utils"; +import { NativeAppStartResponse } from "../definitions"; import { StallTracking } from "../integrations"; import { RoutingInstrumentationInstance } from "../tracing/routingInstrumentation"; -import { adjustTransactionDuration } from "./utils"; +import { NATIVE } from "../wrapper"; +import { adjustTransactionDuration, getTimeOriginMilliseconds } from "./utils"; export type BeforeNavigate = ( context: TransactionContext @@ -64,6 +68,14 @@ export interface ReactNativeTracingOptions * @returns A (potentially) modified context object, with `sampled = false` if the transaction should be dropped. */ beforeNavigate: BeforeNavigate; + + /** + * Track the app start time by adding measurements to the first route transaction. If there is no routing instrumentation + * an app start transaction will be started. + * + * Default: true + */ + enableAppStartTracking: boolean; } const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { @@ -72,6 +84,7 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { maxTransactionDuration: 600, ignoreEmptyBackNavigationTransactions: true, beforeNavigate: (context) => context, + enableAppStartTracking: true, }; /** @@ -91,6 +104,7 @@ export class ReactNativeTracing implements Integration { public options: ReactNativeTracingOptions; private _getCurrentHub?: () => Hub; + private _awaitingAppStartData?: NativeAppStartResponse; public constructor(options: Partial = {}) { this.options = { @@ -119,12 +133,14 @@ export class ReactNativeTracing implements Integration { this._getCurrentHub = getCurrentHub; - routingInstrumentation?.registerRoutingInstrumentation( - this._onRouteWillChange.bind(this), - this.options.beforeNavigate - ); + void this._instrumentAppStart(); - if (!routingInstrumentation) { + if (routingInstrumentation) { + routingInstrumentation.registerRoutingInstrumentation( + this._onRouteWillChange.bind(this), + this.options.beforeNavigate + ); + } else { logger.log( `[ReactNativeTracing] Not instrumenting route changes as routingInstrumentation has not been set.` ); @@ -138,6 +154,73 @@ export class ReactNativeTracing implements Integration { }); } + /** + * Instruments the app start measurements on the first route transaction. + * Starts a route transaction if there isn't routing instrumentation. + */ + private async _instrumentAppStart(): Promise { + if (!this.options.enableAppStartTracking || !NATIVE.enableNative) { + return; + } + + const appStart = await NATIVE.fetchNativeAppStart(); + + if (!appStart || appStart.didFetchAppStart) { + return; + } + + if (this.options.routingInstrumentation) { + this._awaitingAppStartData = appStart; + } else { + const appStartTimeSeconds = appStart.appStartTime / 1000; + + const idleTransaction = this._createRouteTransaction({ + name: "App Start", + op: "ui.load", + startTimestamp: appStartTimeSeconds, + }); + + if (idleTransaction) { + this._addAppStartData(idleTransaction, appStart); + } + } + } + + /** + * Adds app start measurements and starts a child span on a transaction. + */ + private _addAppStartData( + transaction: IdleTransaction, + appStart: NativeAppStartResponse + ): void { + const appStartTimeSeconds = appStart.appStartTime / 1000; + const timeOriginSeconds = getTimeOriginMilliseconds() / 1000; + + transaction.startChild({ + description: appStart.isColdStart ? "Cold App Start" : "Warm App Start", + op: appStart.isColdStart ? "app.start.cold" : "app.start.warm", + startTimestamp: appStartTimeSeconds, + endTimestamp: timeOriginSeconds, + }); + + const appStartDurationMilliseconds = + getTimeOriginMilliseconds() - appStart.appStartTime; + + transaction.setMeasurements( + appStart.isColdStart + ? { + app_start_cold: { + value: appStartDurationMilliseconds, + }, + } + : { + app_start_warm: { + value: appStartDurationMilliseconds, + }, + } + ); + } + /** To be called when the route changes, but BEFORE the components of the new route mount. */ private _onRouteWillChange( context: TransactionContext @@ -149,7 +232,7 @@ export class ReactNativeTracing implements Integration { /** Create routing idle transaction. */ private _createRouteTransaction( context: TransactionContext - ): TransactionType | undefined { + ): IdleTransaction | undefined { if (!this._getCurrentHub) { logger.warn( `[ReactNativeTracing] Did not create ${context.op} transaction because _getCurrentHub is invalid.` @@ -172,10 +255,23 @@ export class ReactNativeTracing implements Integration { idleTimeout, true ); + logger.log( `[ReactNativeTracing] Starting ${context.op} transaction "${context.name}" on scope` ); + idleTransaction.registerBeforeFinishCallback((transaction) => { + if (this.options.enableAppStartTracking && this._awaitingAppStartData) { + transaction.startTimestamp = + this._awaitingAppStartData.appStartTime / 1000; + transaction.op = "ui.load"; + + this._addAppStartData(transaction, this._awaitingAppStartData); + + this._awaitingAppStartData = undefined; + } + }); + const stallTracking = this._getCurrentHub().getIntegration(StallTracking); if (stallTracking) { @@ -223,6 +319,6 @@ export class ReactNativeTracing implements Integration { }); } - return idleTransaction as TransactionType; + return idleTransaction; } } diff --git a/src/js/tracing/utils.ts b/src/js/tracing/utils.ts index ebdd810688..7d5336f115 100644 --- a/src/js/tracing/utils.ts +++ b/src/js/tracing/utils.ts @@ -1,5 +1,7 @@ import { IdleTransaction, SpanStatus } from "@sentry/tracing"; +const timeOriginMilliseconds = Date.now(); + /** * Converts from seconds to milliseconds * @param time time in seconds @@ -24,3 +26,10 @@ export function adjustTransactionDuration( transaction.setTag("maxTransactionDurationExceeded", "true"); } } + +/** + * Returns the timestamp where the JS global scope was initialized. + */ +export function getTimeOriginMilliseconds(): number { + return timeOriginMilliseconds; +} diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index 9f77e245b7..1c0c837695 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -11,6 +11,7 @@ import { logger, SentryError } from "@sentry/utils"; import { NativeModules, Platform } from "react-native"; import { + NativeAppStartResponse, NativeDeviceContextsResponse, NativeReleaseResponse, SentryNativeBridgeModule, @@ -43,6 +44,7 @@ interface SentryNativeWrapper { fetchNativeRelease(): PromiseLike; fetchNativeDeviceContexts(): PromiseLike; + fetchNativeAppStart(): PromiseLike; addBreadcrumb(breadcrumb: Breadcrumb): void; setContext(key: string, context: { [key: string]: unknown } | null): void; @@ -247,6 +249,17 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.fetchNativeDeviceContexts(); }, + async fetchNativeAppStart(): Promise { + if (!this.enableNative) { + throw this._DisabledNativeError; + } + if (!this._isModuleLoaded(RNSentry)) { + throw this._NativeClientError; + } + + return RNSentry.fetchNativeAppStart(); + }, + /** * Triggers a native crash. * Use this only for testing purposes. diff --git a/test/sdk.test.ts b/test/sdk.test.ts index a8a3fca52e..68739b246b 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -49,6 +49,7 @@ import { getCurrentHub } from "@sentry/react"; import { StallTracking } from "../src/js/integrations"; import { flush, init } from "../src/js/sdk"; +import { ReactNativeTracing } from "../src/js/tracing"; afterEach(() => { jest.clearAllMocks(); @@ -100,6 +101,71 @@ describe("Tests the SDK functionality", () => { expect(stallTrackingIsEnabled()).toBe(true); }); + + it("Stall Tracking is disabled when Auto performance tracking is disabled", () => { + init({ + tracesSampleRate: 0.5, + enableStallTracking: true, + enableAutoPerformanceTracking: false, + }); + + expect(stallTrackingIsEnabled()).toBe(false); + }); + + it("Stall Tracking is enabled when Auto performance tracking is enabled", () => { + init({ + tracesSampleRate: 0.5, + enableStallTracking: true, + enableAutoPerformanceTracking: true, + }); + + expect(stallTrackingIsEnabled()).toBe(true); + }); + }); + }); + + describe("enableAutoPerformanceTracking", () => { + const autoPerformanceIsEnabled = (): boolean => { + const mockCall = (initAndBind as jest.MockedFunction) + .mock.calls[0]; + + if (mockCall) { + const options = mockCall[1]; + + if (options.defaultIntegrations) { + return options.defaultIntegrations?.some( + (integration) => integration.name === ReactNativeTracing.id + ); + } + } + + return false; + }; + + it("Auto Performance is not enabled when tracing is disabled", () => { + init({ + enableStallTracking: true, + }); + + expect(autoPerformanceIsEnabled()).toBe(false); + }); + + it("Auto Performance is enabled when tracing is enabled (tracesSampler)", () => { + init({ + tracesSampler: () => true, + enableAutoPerformanceTracking: true, + }); + + expect(autoPerformanceIsEnabled()).toBe(true); + }); + + it("Auto Performance is enabled when tracing is enabled (tracesSampleRate)", () => { + init({ + tracesSampleRate: 0.5, + enableAutoPerformanceTracking: true, + }); + + expect(autoPerformanceIsEnabled()).toBe(true); }); }); diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts new file mode 100644 index 0000000000..177651dcdc --- /dev/null +++ b/test/tracing/reactnativetracing.test.ts @@ -0,0 +1,456 @@ +import { BrowserClient } from "@sentry/browser"; +import { addGlobalEventProcessor, Hub } from "@sentry/hub"; +import { IdleTransaction, Transaction } from "@sentry/tracing"; + +import { NativeAppStartResponse } from "../../src/js/definitions"; +import { RoutingInstrumentation } from "../../src/js/tracing/routingInstrumentation"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mockFunction any>( + fn: T +): jest.MockedFunction { + return fn as jest.MockedFunction; +} + +jest.mock("../../src/js/wrapper", () => { + return { + NATIVE: { + fetchNativeAppStart: jest.fn(), + enableNative: true, + }, + }; +}); + +jest.mock("../../src/js/tracing/utils", () => { + const originalUtils = jest.requireActual("../../src/js/tracing/utils"); + + return { + ...originalUtils, + getTimeOriginMilliseconds: jest.fn(), + }; +}); + +const getMockHub = () => { + const mockHub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + let scopeTransaction: Transaction | undefined; + const mockScope = { + getTransaction: () => scopeTransaction, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setSpan(span: any) { + scopeTransaction = span; + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockHub.getScope = () => mockScope as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockHub.configureScope = jest.fn((callback) => callback(mockScope as any)); + + return mockHub; +}; + +import { ReactNativeTracing } from "../../src/js/tracing/reactnativetracing"; +import { getTimeOriginMilliseconds } from "../../src/js/tracing/utils"; +import { NATIVE } from "../../src/js/wrapper"; + +beforeEach(() => { + NATIVE.enableNative = true; + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("ReactNativeTracing", () => { + describe("App Start", () => { + describe("Without routing instrumentation", () => { + it("Starts route transaction (cold)", (done) => { + const integration = new ReactNativeTracing(); + + const timeOriginMilliseconds = Date.now(); + const appStartTimeMilliseconds = timeOriginMilliseconds - 100; + const mockAppStartResponse: NativeAppStartResponse = { + isColdStart: true, + appStartTime: appStartTimeMilliseconds, + didFetchAppStart: false, + }; + + mockFunction(getTimeOriginMilliseconds).mockReturnValue( + timeOriginMilliseconds + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue( + mockAppStartResponse + ); + + const mockHub = getMockHub(); + integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + // use setImmediate as app start is handled inside a promise. + setImmediate(() => { + const transaction = mockHub.getScope()?.getTransaction(); + + expect(transaction).toBeDefined(); + + jest.runOnlyPendingTimers(); + + if (transaction) { + expect(transaction.startTimestamp).toBe( + appStartTimeMilliseconds / 1000 + ); + expect(transaction.op).toBe("ui.load"); + + expect( + // @ts-ignore access private for test + transaction._measurements?.app_start_cold?.value + ).toEqual(timeOriginMilliseconds - appStartTimeMilliseconds); + + done(); + } + }); + }); + + it("Starts route transaction (warm)", (done) => { + const integration = new ReactNativeTracing(); + + const timeOriginMilliseconds = Date.now(); + const appStartTimeMilliseconds = timeOriginMilliseconds - 100; + const mockAppStartResponse: NativeAppStartResponse = { + isColdStart: false, + appStartTime: appStartTimeMilliseconds, + didFetchAppStart: false, + }; + + mockFunction(getTimeOriginMilliseconds).mockReturnValue( + timeOriginMilliseconds + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue( + mockAppStartResponse + ); + + const mockHub = getMockHub(); + integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + // use setImmediate as app start is handled inside a promise. + setImmediate(() => { + const transaction = mockHub.getScope()?.getTransaction(); + + expect(transaction).toBeDefined(); + + jest.runOnlyPendingTimers(); + + if (transaction) { + expect(transaction.startTimestamp).toBe( + appStartTimeMilliseconds / 1000 + ); + expect(transaction.op).toBe("ui.load"); + + expect( + // @ts-ignore access private for test + transaction._measurements?.app_start_warm?.value + ).toEqual(timeOriginMilliseconds - appStartTimeMilliseconds); + + done(); + } + }); + }); + + it("Does not create app start transaction if didFetchAppStart == true", (done) => { + const integration = new ReactNativeTracing(); + + const timeOriginMilliseconds = Date.now(); + const appStartTimeMilliseconds = timeOriginMilliseconds - 100; + const mockAppStartResponse: NativeAppStartResponse = { + isColdStart: true, + appStartTime: appStartTimeMilliseconds, + didFetchAppStart: true, + }; + + mockFunction(getTimeOriginMilliseconds).mockReturnValue( + timeOriginMilliseconds + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue( + mockAppStartResponse + ); + + const mockHub = getMockHub(); + integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + // use setImmediate as app start is handled inside a promise. + setImmediate(() => { + const transaction = mockHub.getScope()?.getTransaction(); + + expect(transaction).toBeUndefined(); + + jest.runOnlyPendingTimers(); + + done(); + }); + }); + }); + + describe("With routing instrumentation", () => { + it("Adds measurements and child span onto existing routing transaction and sets the op (cold)", (done) => { + const routingInstrumentation = new RoutingInstrumentation(); + const integration = new ReactNativeTracing({ + routingInstrumentation, + }); + + const timeOriginMilliseconds = Date.now(); + const appStartTimeMilliseconds = timeOriginMilliseconds - 100; + const mockAppStartResponse: NativeAppStartResponse = { + isColdStart: true, + appStartTime: appStartTimeMilliseconds, + didFetchAppStart: false, + }; + + mockFunction(getTimeOriginMilliseconds).mockReturnValue( + timeOriginMilliseconds + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue( + mockAppStartResponse + ); + + const mockHub = getMockHub(); + integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + // use setImmediate as app start is handled inside a promise. + setImmediate(() => { + const transaction = mockHub.getScope()?.getTransaction(); + + expect(transaction).toBeUndefined(); + + const routeTransaction = routingInstrumentation.onRouteWillChange({ + name: "test", + }) as IdleTransaction; + routeTransaction.initSpanRecorder(10); + + expect(routeTransaction).toBeDefined(); + expect(routeTransaction).toBe(mockHub.getScope()?.getTransaction()); + + if (routeTransaction) { + jest.runOnlyPendingTimers(); + + // @ts-ignore access private for test + expect(routeTransaction._measurements?.app_start_cold?.value).toBe( + timeOriginMilliseconds - appStartTimeMilliseconds + ); + + expect(routeTransaction.op).toBe("ui.load"); + expect(routeTransaction.startTimestamp).toBe( + appStartTimeMilliseconds / 1000 + ); + + const spanRecorder = routeTransaction.spanRecorder; + expect(spanRecorder).toBeDefined(); + if (spanRecorder) { + expect(spanRecorder.spans.length).toBe(2); + + const span = spanRecorder.spans[1]; + + expect(span.op).toBe("app.start.cold"); + expect(span.description).toBe("Cold App Start"); + expect(span.startTimestamp).toBe(appStartTimeMilliseconds / 1000); + expect(span.endTimestamp).toBe(timeOriginMilliseconds / 1000); + } + + done(); + } + }); + }); + + it("Adds measurements and child span onto existing routing transaction and sets the op (cold)", (done) => { + const routingInstrumentation = new RoutingInstrumentation(); + const integration = new ReactNativeTracing({ + routingInstrumentation, + }); + + const timeOriginMilliseconds = Date.now(); + const appStartTimeMilliseconds = timeOriginMilliseconds - 100; + const mockAppStartResponse: NativeAppStartResponse = { + isColdStart: false, + appStartTime: appStartTimeMilliseconds, + didFetchAppStart: false, + }; + + mockFunction(getTimeOriginMilliseconds).mockReturnValue( + timeOriginMilliseconds + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue( + mockAppStartResponse + ); + + const mockHub = getMockHub(); + integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + // use setImmediate as app start is handled inside a promise. + setImmediate(() => { + const transaction = mockHub.getScope()?.getTransaction(); + + expect(transaction).toBeUndefined(); + + const routeTransaction = routingInstrumentation.onRouteWillChange({ + name: "test", + }) as IdleTransaction; + routeTransaction.initSpanRecorder(10); + + expect(routeTransaction).toBeDefined(); + expect(routeTransaction).toBe(mockHub.getScope()?.getTransaction()); + + if (routeTransaction) { + jest.runOnlyPendingTimers(); + + // @ts-ignore access private for test + expect(routeTransaction._measurements?.app_start_warm?.value).toBe( + timeOriginMilliseconds - appStartTimeMilliseconds + ); + + expect(routeTransaction.op).toBe("ui.load"); + expect(routeTransaction.startTimestamp).toBe( + appStartTimeMilliseconds / 1000 + ); + + const spanRecorder = routeTransaction.spanRecorder; + expect(spanRecorder).toBeDefined(); + if (spanRecorder) { + expect(spanRecorder.spans.length).toBe(2); + + const span = spanRecorder.spans[1]; + + expect(span.op).toBe("app.start.warm"); + expect(span.description).toBe("Warm App Start"); + expect(span.startTimestamp).toBe(appStartTimeMilliseconds / 1000); + expect(span.endTimestamp).toBe(timeOriginMilliseconds / 1000); + } + + done(); + } + }); + }); + + it("Does not update route transaction if didFetchAppStart == true", (done) => { + const routingInstrumentation = new RoutingInstrumentation(); + const integration = new ReactNativeTracing({ + routingInstrumentation, + }); + + const timeOriginMilliseconds = Date.now(); + const appStartTimeMilliseconds = timeOriginMilliseconds - 100; + const mockAppStartResponse: NativeAppStartResponse = { + isColdStart: false, + appStartTime: appStartTimeMilliseconds, + didFetchAppStart: true, + }; + + mockFunction(getTimeOriginMilliseconds).mockReturnValue( + timeOriginMilliseconds + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue( + mockAppStartResponse + ); + + const mockHub = getMockHub(); + integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + // use setImmediate as app start is handled inside a promise. + setImmediate(() => { + const transaction = mockHub.getScope()?.getTransaction(); + + expect(transaction).toBeUndefined(); + + const routeTransaction = routingInstrumentation.onRouteWillChange({ + name: "test", + }) as IdleTransaction; + routeTransaction.initSpanRecorder(10); + + expect(routeTransaction).toBeDefined(); + expect(routeTransaction).toBe(mockHub.getScope()?.getTransaction()); + + if (routeTransaction) { + jest.runOnlyPendingTimers(); + + // @ts-ignore access private for test + expect(routeTransaction._measurements).toMatchObject({}); + + expect(routeTransaction.op).not.toBe("ui.load"); + expect(routeTransaction.startTimestamp).not.toBe( + appStartTimeMilliseconds / 1000 + ); + + const spanRecorder = routeTransaction.spanRecorder; + expect(spanRecorder).toBeDefined(); + if (spanRecorder) { + expect(spanRecorder.spans.length).toBe(1); + } + + done(); + } + }); + }); + }); + + it("Does not instrument app start if app start is disabled", (done) => { + const integration = new ReactNativeTracing({ + enableAppStartTracking: false, + }); + const mockHub = getMockHub(); + integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + setImmediate(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(NATIVE.fetchNativeAppStart).not.toBeCalled(); + + const transaction = mockHub.getScope()?.getTransaction(); + + expect(transaction).toBeUndefined(); + + done(); + }); + }); + + it("Does not instrument app start if native is disabled", (done) => { + NATIVE.enableNative = false; + + const integration = new ReactNativeTracing(); + const mockHub = getMockHub(); + integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + setImmediate(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(NATIVE.fetchNativeAppStart).not.toBeCalled(); + + const transaction = mockHub.getScope()?.getTransaction(); + + expect(transaction).toBeUndefined(); + + done(); + }); + }); + + it("Does not instrument app start if fetchNativeAppStart returns null", (done) => { + // eslint-disable-next-line @typescript-eslint/unbound-method + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(null); + + const integration = new ReactNativeTracing(); + const mockHub = getMockHub(); + integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + setImmediate(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(NATIVE.fetchNativeAppStart).toBeCalledTimes(1); + + const transaction = mockHub.getScope()?.getTransaction(); + + expect(transaction).toBeUndefined(); + + done(); + }); + }); + }); +});