diff --git a/CHANGELOG.md b/CHANGELOG.md index f01ac17299..1193f1210b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## Unreleased +### Changes + +- New App Start Integration ([#3852](https://github.com/getsentry/sentry-react-native/pull/3852)) + + By default app start spans are attached to the first created transaction. + Standalone mode creates single root span (transaction) including only app start data. + + ```js + import Sentry from '@sentry/react-native'; + + Sentry.init({ + tracesSampleRate: 1.0, + enableAppStartTracking: true, // default true + integrations: [ + Sentry.appStartIntegration({ + standalone: false, // default false + }), + ], + }); + ``` + ### Fixes - Pass `sampleRate` option to the Android SDK ([#3979](https://github.com/getsentry/sentry-react-native/pull/3979)) diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 25f9cc4751..ece3bb0ae9 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -88,6 +88,9 @@ Sentry.init({ maskAllVectors: true, // maskAllText: false, }), + Sentry.appStartIntegration({ + standalone: false, + }), ); return integrations.filter(i => i.name !== 'Dedupe'); }, diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index e6efd90786..edf7011479 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -6,6 +6,7 @@ import type { ReactNativeClientOptions } from '../options'; import { ReactNativeTracing } from '../tracing'; import { isExpoGo, notWeb } from '../utils/environment'; import { + appStartIntegration, breadcrumbsIntegration, browserApiErrorsIntegration, browserGlobalHandlersIntegration, @@ -100,6 +101,9 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ if (hasTracingEnabled && options.enableAutoPerformanceTracing) { integrations.push(new ReactNativeTracing()); } + if (hasTracingEnabled && options.enableAppStartTracking) { + integrations.push(appStartIntegration()); + } if (options.enableCaptureFailedRequests) { integrations.push(httpClientIntegration()); } diff --git a/src/js/integrations/exports.ts b/src/js/integrations/exports.ts index 1bb337d5c3..2a34136673 100644 --- a/src/js/integrations/exports.ts +++ b/src/js/integrations/exports.ts @@ -13,6 +13,7 @@ export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; export { spotlightIntegration } from './spotlight'; export { mobileReplayIntegration } from '../replay/mobilereplay'; +export { appStartIntegration } from '../tracing/integrations/appStart'; export { breadcrumbsIntegration, diff --git a/src/js/options.ts b/src/js/options.ts index 0c5a4baa43..1d4451a952 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -188,6 +188,16 @@ export interface BaseReactNativeOptions { */ beforeScreenshot?: (event: Event, hint: EventHint) => boolean; + /** + * 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. + * + * Requires performance monitoring to be enabled. + * + * Default: true + */ + enableAppStartTracking?: boolean; + /** * Options which are in beta, or otherwise not guaranteed to be stable. */ diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index 776fe00bff..246f635eed 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -14,7 +14,6 @@ import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOp import { shouldEnableNativeNagger } from './options'; import { enableSyncToNative } from './scopeSync'; import { TouchEventBoundary } from './touchevents'; -import type { ReactNativeTracing } from './tracing'; import { ReactNativeProfiler } from './tracing'; import { useEncodePolyfill } from './transports/encodePolyfill'; import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native'; @@ -34,6 +33,7 @@ const DEFAULT_OPTIONS: ReactNativeOptions = { attachStacktrace: true, enableCaptureFailedRequests: false, enableNdk: true, + enableAppStartTracking: true, }; /** @@ -112,11 +112,6 @@ export function wrap

>( RootComponent: React.ComponentType

, options?: ReactNativeWrapperOptions ): React.ComponentType

{ - const tracingIntegration = getClient()?.getIntegrationByName?.('ReactNativeTracing') as ReactNativeTracing | undefined; - if (tracingIntegration) { - tracingIntegration.useAppStartWithProfiler = true; - } - const profilerProps = { ...(options?.profilerProps ?? {}), name: RootComponent.displayName ?? 'Root', diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts new file mode 100644 index 0000000000..db7bd365b2 --- /dev/null +++ b/src/js/tracing/integrations/appStart.ts @@ -0,0 +1,394 @@ +/* eslint-disable complexity */ +import { + getCapturedScopesOnSpan, + getClient, + getCurrentScope, + SentryNonRecordingSpan, + startInactiveSpan, +} from '@sentry/core'; +import type { Client, Event, Integration, SpanJSON, TransactionEvent } from '@sentry/types'; +import { logger, timestampInSeconds } from '@sentry/utils'; + +import { + APP_START_COLD as APP_START_COLD_MEASUREMENT, + APP_START_WARM as APP_START_WARM_MEASUREMENT, +} from '../../measurements'; +import type { NativeAppStartResponse } from '../../NativeRNSentry'; +import type { ReactNativeClientOptions } from '../../options'; +import { convertSpanToTransaction, setEndTimeValue } from '../../utils/span'; +import { NATIVE } from '../../wrapper'; +import { + APP_START_COLD as APP_START_COLD_OP, + APP_START_WARM as APP_START_WARM_OP, + UI_LOAD as UI_LOAD_OP, +} from '../ops'; +import { ReactNativeTracing } from '../reactnativetracing'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes'; +import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs } from '../utils'; + +const INTEGRATION_NAME = 'AppStart'; + +export type AppStartIntegration = Integration & { + captureStandaloneAppStart: () => Promise; +}; + +/** + * We filter out app start more than 60s. + * This could be due to many different reasons. + * We've seen app starts with hours, days and even months. + */ +const MAX_APP_START_DURATION_MS = 60_000; + +/** We filter out App starts which timestamp is 60s and more before the transaction start */ +const MAX_APP_START_AGE_MS = 60_000; + +/** App Start transaction name */ +const APP_START_TX_NAME = 'App Start'; + +let recordedAppStartEndTimestampMs: number | undefined = undefined; +let rootComponentCreationTimestampMs: number | undefined = undefined; + +/** + * Records the application start end. + * Used automatically by `Sentry.wrap` and `Sentry.ReactNativeProfiler`. + */ +export async function captureAppStart(): Promise { + const client = getClient(); + if (!client) { + logger.warn('[AppStart] Could not capture App Start, missing client.'); + return; + } + + _setAppStartEndTimestampMs(timestampInSeconds() * 1000); + await client.getIntegrationByName(INTEGRATION_NAME)?.captureStandaloneAppStart(); +} + +/** + * Sets the root component first constructor call timestamp. + * Used automatically by `Sentry.wrap` and `Sentry.ReactNativeProfiler`. + */ +export function setRootComponentCreationTimestampMs(timestampMs: number): void { + recordedAppStartEndTimestampMs && + logger.warn('Setting Root component creation timestamp after app start end is set.'); + rootComponentCreationTimestampMs && logger.warn('Overwriting already set root component creation timestamp.'); + rootComponentCreationTimestampMs = timestampMs; +} + +/** + * For internal use only. + * + * @private + */ +export const _setAppStartEndTimestampMs = (timestampMs: number): void => { + recordedAppStartEndTimestampMs && logger.warn('Overwriting already set app start.'); + recordedAppStartEndTimestampMs = timestampMs; +}; + +/** + * For testing purposes only. + * + * @private + */ +export function _clearRootComponentCreationTimestampMs(): void { + rootComponentCreationTimestampMs = undefined; +} + +/** + * Adds AppStart spans from the native layer to the transaction event. + */ +export const appStartIntegration = ({ + standalone: standaloneUserOption, +}: { + /** + * Should the integration send App Start as a standalone root span (transaction)? + * If false, App Start will be added as a child span to the first transaction. + * + * @default false + */ + standalone?: boolean; +} = {}): AppStartIntegration => { + let _client: Client | undefined = undefined; + let standalone = standaloneUserOption; + let isEnabled = true; + let appStartDataFlushed = false; + + const setup = (client: Client): void => { + _client = client; + const clientOptions = client.getOptions() as ReactNativeClientOptions; + + const { enableAppStartTracking } = clientOptions; + if (!enableAppStartTracking) { + isEnabled = false; + logger.warn('[AppStart] App start tracking is disabled.'); + } + }; + + const afterAllSetup = (client: Client): void => { + if (standaloneUserOption === undefined) { + // If not user defined, set based on the routing instrumentation presence + standalone = !client.getIntegrationByName(ReactNativeTracing.id)?.options + .routingInstrumentation; + } + }; + + const processEvent = async (event: Event): Promise => { + if (!isEnabled || standalone) { + return event; + } + + if (event.type !== 'transaction') { + // App start data is only relevant for transactions + return event; + } + + await attachAppStartToTransactionEvent(event as TransactionEvent); + + return event; + }; + + async function captureStandaloneAppStart(): Promise { + if (!standalone) { + logger.debug( + '[AppStart] App start tracking is enabled. App start will be added to the first transaction as a child span.', + ); + return; + } + + logger.debug('[AppStart] App start tracking standalone root span (transaction).'); + + const span = startInactiveSpan({ + forceTransaction: true, + name: APP_START_TX_NAME, + op: UI_LOAD_OP, + }); + if (span instanceof SentryNonRecordingSpan) { + // Tracing is disabled or the transaction was sampled + return; + } + + setEndTimeValue(span, timestampInSeconds()); + _client.emit('spanEnd', span); + + const event = convertSpanToTransaction(span); + if (!event) { + logger.warn('[AppStart] Failed to convert App Start span to transaction.'); + return; + } + + await attachAppStartToTransactionEvent(event); + if (!event.spans || event.spans.length === 0) { + // No spans were added to the transaction, so we don't need to send it + return; + } + + const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope(); + scope.captureEvent(event); + } + + async function attachAppStartToTransactionEvent(event: TransactionEvent): Promise { + if (appStartDataFlushed) { + // App start data is only relevant for the first transaction + return; + } + + if (!event.contexts || !event.contexts.trace) { + logger.warn('[AppStart] Transaction event is missing trace context. Can not attach app start.'); + return; + } + + const appStart = await NATIVE.fetchNativeAppStart(); + if (!appStart) { + logger.warn('[AppStart] Failed to retrieve the app start metrics from the native layer.'); + return; + } + if (appStart.has_fetched) { + logger.warn('[AppStart] Measured app start metrics were already reported from the native layer.'); + return; + } + + const appStartTimestampMs = appStart.app_start_timestamp_ms; + if (!appStartTimestampMs) { + logger.warn('[AppStart] App start timestamp could not be loaded from the native layer.'); + return; + } + + const appStartEndTimestampMs = recordedAppStartEndTimestampMs || getBundleStartTimestampMs(); + if (!appStartEndTimestampMs) { + logger.warn( + '[AppStart] Javascript failed to record app start end. `setAppStartEndTimestampMs` was not called nor could the bundle start be found.', + ); + return; + } + + const isAppStartWithinBounds = + !!event.start_timestamp && appStartTimestampMs >= event.start_timestamp - MAX_APP_START_AGE_MS; + if (!__DEV__ && !isAppStartWithinBounds) { + logger.warn('[AppStart] App start timestamp is too far in the past to be used for app start span.'); + return; + } + + const appStartDurationMs = appStartEndTimestampMs - appStartTimestampMs; + if (!__DEV__ && appStartDurationMs >= MAX_APP_START_DURATION_MS) { + // Dev builds can have long app start waiting over minute for the first bundle to be produced + logger.warn('[AppStart] App start duration is over a minute long, not adding app start span.'); + return; + } + + appStartDataFlushed = true; + + event.contexts.trace.data = event.contexts.trace.data || {}; + event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = UI_LOAD_OP; + event.contexts.trace.op = UI_LOAD_OP; + + const appStartTimestampSeconds = appStartTimestampMs / 1000; + event.start_timestamp = appStartTimestampSeconds; + + event.spans = event.spans || []; + /** event.spans reference */ + const children: SpanJSON[] = event.spans; + + const maybeTtidSpan = children.find(({ op }) => op === 'ui.load.initial_display'); + if (maybeTtidSpan) { + maybeTtidSpan.start_timestamp = appStartTimestampSeconds; + setSpanDurationAsMeasurementOnTransactionEvent(event, 'time_to_initial_display', maybeTtidSpan); + } + + const maybeTtfdSpan = children.find(({ op }) => op === 'ui.load.full_display'); + if (maybeTtfdSpan) { + maybeTtfdSpan.start_timestamp = appStartTimestampSeconds; + setSpanDurationAsMeasurementOnTransactionEvent(event, 'time_to_full_display', maybeTtfdSpan); + } + + const appStartEndTimestampSeconds = appStartEndTimestampMs / 1000; + if (event.timestamp && event.timestamp < appStartEndTimestampSeconds) { + logger.debug( + '[AppStart] Transaction event timestamp is before app start end. Adjusting transaction event timestamp.', + ); + event.timestamp = appStartEndTimestampSeconds; + } + + const op = appStart.type === 'cold' ? APP_START_COLD_OP : APP_START_WARM_OP; + const appStartSpanJSON: SpanJSON = createSpanJSON({ + op, + description: appStart.type === 'cold' ? 'Cold App Start' : 'Warm App Start', + start_timestamp: appStartTimestampSeconds, + timestamp: appStartEndTimestampSeconds, + trace_id: event.contexts.trace.trace_id, + parent_span_id: event.contexts.trace.span_id, + origin: 'auto', + }); + const jsExecutionSpanJSON = createJSExecutionStartSpan(appStartSpanJSON, rootComponentCreationTimestampMs); + + const appStartSpans = [ + appStartSpanJSON, + ...(jsExecutionSpanJSON ? [jsExecutionSpanJSON] : []), + ...convertNativeSpansToSpanJSON(appStartSpanJSON, appStart.spans), + ]; + + children.push(...appStartSpans); + logger.debug('[AppStart] Added app start spans to transaction event.', JSON.stringify(appStartSpans, undefined, 2)); + + const measurementKey = appStart.type === 'cold' ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT; + const measurementValue = { + value: appStartDurationMs, + unit: 'millisecond', + }; + event.measurements = event.measurements || {}; + event.measurements[measurementKey] = measurementValue; + logger.debug( + `[AppStart] Added app start measurement to transaction event.`, + JSON.stringify(measurementValue, undefined, 2), + ); + } + + return { + name: INTEGRATION_NAME, + setup, + afterAllSetup, + processEvent, + captureStandaloneAppStart, + }; +}; + +function setSpanDurationAsMeasurementOnTransactionEvent(event: TransactionEvent, label: string, span: SpanJSON): void { + if (!span.timestamp || !span.start_timestamp) { + logger.warn('Span is missing start or end timestamp. Cam not set measurement on transaction event.'); + return; + } + + event.measurements = event.measurements || {}; + event.measurements[label] = { + value: (span.timestamp - span.start_timestamp) * 1000, + unit: 'millisecond', + }; +} + +/** + * Adds JS Execution before React Root. If `Sentry.wrap` is not used, create a span for the start of JS Bundle execution. + */ +function createJSExecutionStartSpan( + parentSpan: SpanJSON, + rootComponentCreationTimestampMs: number | undefined, +): SpanJSON | undefined { + const bundleStartTimestampMs = getBundleStartTimestampMs(); + if (!bundleStartTimestampMs) { + return undefined; + } + + if (!rootComponentCreationTimestampMs) { + logger.warn('Missing the root component first constructor call timestamp.'); + return createChildSpanJSON(parentSpan, { + description: 'JS Bundle Execution Start', + start_timestamp: bundleStartTimestampMs / 1000, + timestamp: bundleStartTimestampMs / 1000, + }); + } + + return createChildSpanJSON(parentSpan, { + description: 'JS Bundle Execution Before React Root', + start_timestamp: bundleStartTimestampMs / 1000, + timestamp: rootComponentCreationTimestampMs / 1000, + }); +} + +/** + * Adds native spans to the app start span. + */ +function convertNativeSpansToSpanJSON(parentSpan: SpanJSON, nativeSpans: NativeAppStartResponse['spans']): SpanJSON[] { + return nativeSpans.map(span => { + if (span.description === 'UIKit init') { + return createUIKitSpan(parentSpan, span); + } + + return createChildSpanJSON(parentSpan, { + description: span.description, + start_timestamp: span.start_timestamp_ms / 1000, + timestamp: span.end_timestamp_ms / 1000, + }); + }); +} + +/** + * UIKit init is measured by the native layers till the native SDK start + * RN initializes the native SDK later, the end timestamp would be wrong + */ +function createUIKitSpan(parentSpan: SpanJSON, nativeUIKitSpan: NativeAppStartResponse['spans'][number]): SpanJSON { + const bundleStart = getBundleStartTimestampMs(); + + // If UIKit init ends after the bundle start, the native SDK was auto-initialized + // and so the end timestamp is incorrect. + // The timestamps can't equal, as RN initializes after UIKit. + if (bundleStart && bundleStart < nativeUIKitSpan.end_timestamp_ms) { + return createChildSpanJSON(parentSpan, { + description: 'UIKit Init to JS Exec Start', + start_timestamp: nativeUIKitSpan.start_timestamp_ms / 1000, + timestamp: bundleStart / 1000, + }); + } else { + return createChildSpanJSON(parentSpan, { + description: 'UIKit Init', + start_timestamp: nativeUIKitSpan.start_timestamp_ms / 1000, + timestamp: nativeUIKitSpan.end_timestamp_ms / 1000, + }); + } +} diff --git a/src/js/tracing/reactnativeprofiler.tsx b/src/js/tracing/reactnativeprofiler.tsx index 80c79f4ce3..7fbb05345e 100644 --- a/src/js/tracing/reactnativeprofiler.tsx +++ b/src/js/tracing/reactnativeprofiler.tsx @@ -1,9 +1,8 @@ -import { spanToJSON } from '@sentry/core'; import { getClient, Profiler } from '@sentry/react'; import { timestampInSeconds } from '@sentry/utils'; import { createIntegration } from '../integrations/factory'; -import type { ReactNativeTracing } from './reactnativetracing'; +import { captureAppStart, setRootComponentCreationTimestampMs } from '../tracing/integrations/appStart'; const ReactNativeProfilerGlobalState = { appStartReported: false, @@ -16,9 +15,7 @@ export class ReactNativeProfiler extends Profiler { public readonly name: string = 'ReactNativeProfiler'; public constructor(props: ConstructorParameters[0]) { - const client = getClient(); - const integration = client && client.getIntegrationByName && client.getIntegrationByName('ReactNativeTracing'); - integration && integration.setRootComponentFirstConstructorCallTimestampMs(timestampInSeconds() * 1000); + setRootComponentCreationTimestampMs(timestampInSeconds() * 1000); super(props); } @@ -47,12 +44,7 @@ export class ReactNativeProfiler extends Profiler { } client.addIntegration && client.addIntegration(createIntegration(this.name)); - - const endTimestamp = this._mountSpan && typeof spanToJSON(this._mountSpan).timestamp - const tracingIntegration = client.getIntegrationByName && client.getIntegrationByName('ReactNativeTracing'); - tracingIntegration - && typeof endTimestamp === 'number' - // The first root component mount is the app start finish. - && tracingIntegration.onAppStartFinish(endTimestamp); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + captureAppStart(); } } diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 4d07c66cb9..0d146190ae 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -4,23 +4,16 @@ import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from import { getActiveSpan, getCurrentScope, - getSpanDescendants, SEMANTIC_ATTRIBUTE_SENTRY_OP, SentryNonRecordingSpan, - setMeasurement, SPAN_STATUS_ERROR, spanToJSON, startIdleSpan, - startInactiveSpan, - startSpanManual, } from '@sentry/core'; import type { Client, Event, Integration, PropagationContext, Scope, Span, StartSpanOptions } from '@sentry/types'; import { logger, uuid4 } from '@sentry/utils'; -import { APP_START_COLD, APP_START_WARM } from '../measurements'; -import type { NativeAppStartResponse } from '../NativeRNSentry'; import type { RoutingInstrumentationInstance } from '../tracing/routingInstrumentation'; -import { isRootSpan, isSentrySpan } from '../utils/span'; import { NATIVE } from '../wrapper'; import { NativeFramesInstrumentation } from './nativeframes'; import { @@ -30,10 +23,8 @@ import { onlySampleIfChildSpans, onThisSpanEnd, } from './onSpanEndUtils'; -import { APP_START_COLD as APP_START_COLD_OP, APP_START_WARM as APP_START_WARM_OP, UI_LOAD } from './ops'; import { StallTrackingInstrumentation } from './stalltracking'; import type { BeforeNavigate } from './types'; -import { getBundleStartTimestampMs, getTimeOriginMilliseconds, setSpanDurationAsMeasurement } from './utils'; const SCOPE_SPAN_FIELD = '_sentrySpan'; @@ -100,14 +91,6 @@ export interface ReactNativeTracingOptions extends RequestInstrumentationOptions */ 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; - /** * Track slow/frozen frames from the native layer and adds them as measurements to all transactions. */ @@ -134,7 +117,6 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { finalTimeoutMs: 600000, ignoreEmptyBackNavigationTransactions: true, beforeNavigate: context => context, - enableAppStartTracking: true, enableNativeFramesTracking: true, enableStallTracking: true, enableUserInteractionTracing: false, @@ -148,10 +130,7 @@ export class ReactNativeTracing implements Integration { * @inheritDoc */ public static id: string = 'ReactNativeTracing'; - /** We filter out App starts more than 60s */ - private static _maxAppStart: number = 60000; - /** We filter out App starts which timestamp is 60s and more before the transaction start */ - private static _maxAppStartBeforeTransactionMs: number = 60000; + /** * @inheritDoc */ @@ -165,13 +144,11 @@ export class ReactNativeTracing implements Integration { public useAppStartWithProfiler: boolean = false; private _inflightInteractionTransaction?: Span; - private _awaitingAppStartData?: NativeAppStartResponse; - private _appStartFinishTimestamp?: number; + private _currentRoute?: string; private _hasSetTracePropagationTargets: boolean; private _currentViewName: string | undefined; private _client: Client | undefined; - private _firstConstructorCallTimestampMs: number | undefined; public constructor(options: Partial = {}) { this._hasSetTracePropagationTargets = !!( @@ -215,7 +192,6 @@ export class ReactNativeTracing implements Integration { // eslint-disable-next-line deprecation/deprecation tracePropagationTargets: thisOptionsTracePropagationTargets, routingInstrumentation, - enableAppStartTracking, enableStallTracking, } = this.options; @@ -225,12 +201,6 @@ export class ReactNativeTracing implements Integration { (this._hasSetTracePropagationTargets && thisOptionsTracePropagationTargets) || DEFAULT_TRACE_PROPAGATION_TARGETS; - if (enableAppStartTracking) { - this._instrumentAppStart().then(undefined, (reason: unknown) => { - logger.error(`[ReactNativeTracing] Error while instrumenting app start:`, reason); - }); - } - this._enableNativeFramesTracking(client); if (enableStallTracking) { @@ -268,20 +238,6 @@ export class ReactNativeTracing implements Integration { : eventWithView; } - /** - * Called by the ReactNativeProfiler component on first component mount. - */ - public onAppStartFinish(endTimestamp: number): void { - this._appStartFinishTimestamp = endTimestamp; - } - - /** - * Sets the root component first constructor call timestamp. - */ - public setRootComponentFirstConstructorCallTimestampMs(timestamp: number): void { - this._firstConstructorCallTimestampMs = timestamp; - } - /** * Starts a new transaction for a user interaction. * @param userInteractionId Consists of `op` representation UI Event and `elementId` unique element identifier on current screen. @@ -400,197 +356,6 @@ export class ReactNativeTracing implements Integration { return event; } - /** - * Returns the App Start Duration in Milliseconds. Also returns undefined if not able do - * define the duration. - */ - private _getAppStartDurationMilliseconds(appStartTimestampMs: number): number | undefined { - if (!this._appStartFinishTimestamp) { - return undefined; - } - return this._appStartFinishTimestamp * 1000 - appStartTimestampMs; - } - - /** - * 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) { - logger.warn('[ReactNativeTracing] Not instrumenting App Start because native returned null.'); - return; - } - - if (appStart.has_fetched) { - logger.warn('[ReactNativeTracing] Not instrumenting App Start because this start was already reported.'); - return; - } - - if (!this.useAppStartWithProfiler) { - logger.warn('[ReactNativeTracing] `Sentry.wrap` not detected, using JS context init as app start end.'); - this._appStartFinishTimestamp = getTimeOriginMilliseconds() / 1000; - } - - if (this.options.routingInstrumentation) { - this._awaitingAppStartData = appStart; - } else { - const idleTransaction = this._createRouteTransaction({ - name: 'App Start', - op: UI_LOAD, - }); - - if (idleTransaction) { - this._addAppStartData(idleTransaction, appStart); - } - } - } - - /** - * Adds app start measurements and starts a child span on a transaction. - */ - private _addAppStartData(span: Span, appStart: NativeAppStartResponse): void { - const appStartTimestampMs = appStart.app_start_timestamp_ms; - if (!appStartTimestampMs) { - logger.warn('App start timestamp could not be loaded from the native layer.'); - return; - } - - if (!isSentrySpan(span)) { - return; - } - - const isAppStartWithinBounds = - appStartTimestampMs >= getSpanStartTimestampMs(span) - ReactNativeTracing._maxAppStartBeforeTransactionMs; - if (!__DEV__ && !isAppStartWithinBounds) { - logger.warn('[ReactNativeTracing] App start timestamp is too far in the past to be used for app start span.'); - return; - } - - const appStartDurationMilliseconds = this._getAppStartDurationMilliseconds(appStartTimestampMs); - - if (!appStartDurationMilliseconds) { - logger.warn('[ReactNativeTracing] App start end has not been recorded, not adding app start span.'); - return; - } - - // we filter out app start more than 60s. - // this could be due to many different reasons. - // we've seen app starts with hours, days and even months. - if (appStartDurationMilliseconds >= ReactNativeTracing._maxAppStart) { - logger.warn('[ReactNativeTracing] App start duration is over a minute long, not adding app start span.'); - return; - } - - const appStartTimeSeconds = appStartTimestampMs / 1000; - - span.updateStartTime(appStartTimeSeconds); - const children = getSpanDescendants(span); - - const maybeTtidSpan = children.find(span => spanToJSON(span).op === 'ui.load.initial_display'); - if (maybeTtidSpan && isSentrySpan(maybeTtidSpan)) { - maybeTtidSpan.updateStartTime(appStartTimeSeconds); - setSpanDurationAsMeasurement('time_to_initial_display', maybeTtidSpan); - } - - const maybeTtfdSpan = children.find(span => spanToJSON(span).op === 'ui.load.full_display'); - if (maybeTtfdSpan && isSentrySpan(maybeTtfdSpan)) { - maybeTtfdSpan.updateStartTime(appStartTimeSeconds); - setSpanDurationAsMeasurement('time_to_full_display', maybeTtfdSpan); - } - - const op = appStart.type === 'cold' ? APP_START_COLD_OP : APP_START_WARM_OP; - startSpanManual( - { - name: appStart.type === 'cold' ? 'Cold App Start' : 'Warm App Start', - op, - startTime: appStartTimeSeconds, - }, - (appStartSpan: Span) => { - this._addJSExecutionBeforeRoot(appStartSpan); - this._addNativeSpansTo(appStartSpan, appStart.spans); - appStartSpan.end(this._appStartFinishTimestamp); - }, - ); - - const measurement = appStart.type === 'cold' ? APP_START_COLD : APP_START_WARM; - setMeasurement(measurement, appStartDurationMilliseconds, 'millisecond'); - } - - /** - * Adds JS Execution before React Root. If `Sentry.wrap` is not used, create a span for the start of JS Bundle execution. - */ - private _addJSExecutionBeforeRoot(appStartSpan: Span): void { - const bundleStartTimestampMs = getBundleStartTimestampMs(); - if (!bundleStartTimestampMs) { - return; - } - - if (!this._firstConstructorCallTimestampMs) { - logger.warn('Missing the root component first constructor call timestamp.'); - startInactiveSpan({ - name: 'JS Bundle Execution Start', - op: spanToJSON(appStartSpan).op, - startTime: bundleStartTimestampMs / 1000, - }).end(bundleStartTimestampMs / 1000); - return; - } - - startInactiveSpan({ - name: 'JS Bundle Execution Before React Root', - op: spanToJSON(appStartSpan).op, - startTime: bundleStartTimestampMs / 1000, - }).end(this._firstConstructorCallTimestampMs / 1000); - } - - /** - * Adds native spans to the app start span. - */ - private _addNativeSpansTo(appStartSpan: Span, nativeSpans: NativeAppStartResponse['spans']): void { - nativeSpans.forEach(span => { - if (span.description === 'UIKit init') { - return this._createUIKitSpan(appStartSpan, span); - } - - startInactiveSpan({ - op: spanToJSON(appStartSpan).op, - name: span.description, - startTime: span.start_timestamp_ms / 1000, - }).end(span.end_timestamp_ms / 1000); - }); - } - - /** - * UIKit init is measured by the native layers till the native SDK start - * RN initializes the native SDK later, the end timestamp would be wrong - */ - private _createUIKitSpan(parentSpan: Span, nativeUIKitSpan: NativeAppStartResponse['spans'][number]): void { - const bundleStart = getBundleStartTimestampMs(); - const parentSpanOp = spanToJSON(parentSpan).op; - - // If UIKit init ends after the bundle start, the native SDK was auto-initialized - // and so the end timestamp is incorrect. - // The timestamps can't equal, as RN initializes after UIKit. - if (bundleStart && bundleStart < nativeUIKitSpan.end_timestamp_ms) { - startInactiveSpan({ - op: parentSpanOp, - name: 'UIKit Init to JS Exec Start', - startTime: nativeUIKitSpan.start_timestamp_ms / 1000, - }).end(bundleStart / 1000); - } else { - startInactiveSpan({ - op: parentSpanOp, - name: 'UIKit Init', - startTime: nativeUIKitSpan.start_timestamp_ms / 1000, - }).end(nativeUIKitSpan.end_timestamp_ms / 1000); - } - } - /** To be called when the route changes, but BEFORE the components of the new route mount. */ private _onRouteWillChange(): Span | undefined { return this._createRouteTransaction(); @@ -636,21 +401,7 @@ export class ReactNativeTracing implements Integration { scope: getCurrentScope(), }; - const addAwaitingAppStartBeforeSpanEnds = (span: Span): void => { - if (!isRootSpan(span)) { - logger.warn('Not sampling empty back spans only works for Sentry Transactions (Root Spans).'); - return; - } - - if (this.options.enableAppStartTracking && this._awaitingAppStartData) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, UI_LOAD); - this._addAppStartData(span, this._awaitingAppStartData); - - this._awaitingAppStartData = undefined; - } - }; - - const idleSpan = this._startIdleSpan(expandedContext, addAwaitingAppStartBeforeSpanEnds); + const idleSpan = this._startIdleSpan(expandedContext); if (!idleSpan) { return undefined; } @@ -702,11 +453,3 @@ function addDefaultOpForSpanFrom(client: Client): void { } }); } - -/** - * Returns transaction start timestamp in milliseconds. - * If start timestamp is not available, returns 0. - */ -function getSpanStartTimestampMs(span: Span): number { - return (spanToJSON(span).start_timestamp || 0) * 1000; -} diff --git a/src/js/tracing/utils.ts b/src/js/tracing/utils.ts index 56774b0ce0..b0dd9e5719 100644 --- a/src/js/tracing/utils.ts +++ b/src/js/tracing/utils.ts @@ -2,11 +2,13 @@ import { getSpanDescendants, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, spanToJSON, } from '@sentry/core'; -import type { MeasurementUnit, Span, TransactionSource } from '@sentry/types'; -import { logger, timestampInSeconds } from '@sentry/utils'; +import type { MeasurementUnit, Span, SpanJSON, TransactionSource } from '@sentry/types'; +import { dropUndefinedKeys, logger, timestampInSeconds, uuid4 } from '@sentry/utils'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; @@ -94,3 +96,40 @@ export function getBundleStartTimestampMs(): number | undefined { const approxStartingTimeOrigin = Date.now() - RN_GLOBAL_OBJ.nativePerformanceNow(); return approxStartingTimeOrigin + bundleStartTime; } + +/** + * Creates valid span JSON object from the given data. + */ +export function createSpanJSON( + from: Partial & Pick, 'description' | 'start_timestamp' | 'timestamp' | 'origin'>, +): SpanJSON { + return dropUndefinedKeys({ + status: 'ok', + ...from, + span_id: from.span_id ? from.span_id : uuid4().substring(16), + trace_id: from.trace_id ? from.trace_id : uuid4(), + data: dropUndefinedKeys({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: from.op, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: from.origin, + ...(from.data ? from.data : {}), + }), + }); +} + +const SENTRY_DEFAULT_ORIGIN = 'manual'; + +/** + * + */ +export function createChildSpanJSON( + parent: SpanJSON, + from: Partial & Pick, 'description' | 'start_timestamp' | 'timestamp'>, +): SpanJSON { + return createSpanJSON({ + op: parent.op, + trace_id: parent.trace_id, + parent_span_id: parent.span_id, + origin: parent.origin || SENTRY_DEFAULT_ORIGIN, + ...from, + }); +} diff --git a/src/js/utils/span.ts b/src/js/utils/span.ts index fd69de0b64..900e2414bc 100644 --- a/src/js/utils/span.ts +++ b/src/js/utils/span.ts @@ -1,5 +1,5 @@ import { getRootSpan, SentrySpan } from '@sentry/core'; -import type { Span } from '@sentry/types'; +import type { Span, TransactionEvent } from '@sentry/types'; /** * @@ -14,3 +14,25 @@ export function isSentrySpan(span: Span): span is SentrySpan { export function isRootSpan(span: Span): boolean { return span === getRootSpan(span); } + +const END_TIME_SCOPE_FIELD = '_endTime'; +const CONVERT_SPAN_TO_TRANSACTION_FIELD = '_convertSpanToTransaction'; + +type SpanWithPrivate = Span & { + [END_TIME_SCOPE_FIELD]?: number; + [CONVERT_SPAN_TO_TRANSACTION_FIELD]?: () => TransactionEvent | undefined; +}; + +/** + * + */ +export function setEndTimeValue(span: Span, endTimestamp: number): void { + (span as SpanWithPrivate)['_endTime'] = endTimestamp; +} + +/** + * + */ +export function convertSpanToTransaction(span: Span): TransactionEvent | undefined { + return (span as SpanWithPrivate)['_convertSpanToTransaction']?.(); +} diff --git a/test/mocks/client.ts b/test/mocks/client.ts index b8976a41fa..6d6b4898b4 100644 --- a/test/mocks/client.ts +++ b/test/mocks/client.ts @@ -8,7 +8,6 @@ import { setCurrentClient, } from '@sentry/core'; import type { - ClientOptions, Event, EventHint, Integration, @@ -19,6 +18,8 @@ import type { } from '@sentry/types'; import { resolvedSyncPromise } from '@sentry/utils'; +import type { ReactNativeClientOptions } from '../../src/js/options'; + export function getDefaultTestClientOptions(options: Partial = {}): TestClientOptions { return { dsn: 'https://1234@some-domain.com/4505526893805568', @@ -37,7 +38,7 @@ export function getDefaultTestClientOptions(options: Partial }; } -export interface TestClientOptions extends ClientOptions { +export interface TestClientOptions extends ReactNativeClientOptions { test?: boolean; mockInstallFailure?: boolean; enableSend?: boolean; diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 203babf2dd..672dc1959a 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -442,6 +442,35 @@ describe('Tests the SDK functionality', () => { expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Spotlight' })])); }); + it('no app start integration by default', () => { + init({}); + + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; + expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'AppStart' })])); + }); + + it('when tracing enabled app start integration added by default', () => { + init({ + tracesSampleRate: 0.5, + }); + + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; + expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'AppStart' })])); + }); + + it('when tracing enabled and app start disabled the integration is not added', () => { + init({ + tracesSampleRate: 0.5, + enableAppStartTracking: false, + }); + + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; + expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'AppStart' })])); + }); + it('no default integrations', () => { init({ defaultIntegrations: false, diff --git a/test/tracing/integrations/appStart.test.ts b/test/tracing/integrations/appStart.test.ts new file mode 100644 index 0000000000..09e8c53294 --- /dev/null +++ b/test/tracing/integrations/appStart.test.ts @@ -0,0 +1,957 @@ +import { + getCurrentScope, + getGlobalScope, + getIsolationScope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + setCurrentClient, +} from '@sentry/core'; +import type { ErrorEvent, Event, SpanJSON, TransactionEvent } from '@sentry/types'; +import { timestampInSeconds } from '@sentry/utils'; + +import { + APP_START_COLD as APP_START_COLD_MEASUREMENT, + APP_START_WARM as APP_START_WARM_MEASUREMENT, +} from '../../../src/js/measurements'; +import type { NativeAppStartResponse } from '../../../src/js/NativeRNSentry'; +import { + APP_START_COLD as APP_START_COLD_OP, + APP_START_WARM as APP_START_WARM_OP, + UI_LOAD, +} from '../../../src/js/tracing'; +import { + _clearRootComponentCreationTimestampMs, + _setAppStartEndTimestampMs, + appStartIntegration, + setRootComponentCreationTimestampMs, +} from '../../../src/js/tracing/integrations/appStart'; +import { getTimeOriginMilliseconds } from '../../../src/js/tracing/utils'; +import { RN_GLOBAL_OBJ } from '../../../src/js/utils/worldwide'; +import { NATIVE } from '../../../src/js/wrapper'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +import { mockFunction } from '../../testutils'; + +jest.mock('../../../src/js/wrapper', () => { + return { + NATIVE: { + fetchNativeAppStart: jest.fn(), + fetchNativeFrames: jest.fn(() => Promise.resolve()), + disableNativeFramesTracking: jest.fn(() => Promise.resolve()), + enableNativeFramesTracking: jest.fn(() => Promise.resolve()), + enableNative: true, + }, + }; +}); + +jest.mock('../../../src/js/tracing/utils', () => { + const originalUtils = jest.requireActual('../../../src/js/tracing/utils'); + + return { + ...originalUtils, + getTimeOriginMilliseconds: jest.fn(), + }; +}); + +jest.mock('@sentry/utils', () => { + const originalUtils = jest.requireActual('@sentry/utils'); + + return { + ...originalUtils, + timestampInSeconds: jest.fn(originalUtils.timestampInSeconds), + }; +}); + +describe('App Start Integration', () => { + beforeEach(() => { + mockReactNativeBundleExecutionStartTimestamp(); + jest.clearAllMocks(); + }); + + afterEach(() => { + clearReactNativeBundleExecutionStartTimestamp(); + }); + + describe('Standalone App Start', () => { + it('Adds Cold App Start Span to Active Span', async () => { + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStart({ cold: true }); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toEqual( + expectEventWithStandaloneColdAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + }); + + it('Adds Warm App Start Span to Active Span', async () => { + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStart({ cold: false }); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toEqual( + expectEventWithStandaloneWarmAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + }); + + it('Does not add any spans or measurements when App Start Span is longer than threshold', async () => { + set__DEV__(false); + mockTooLongAppStart(); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toStrictEqual(undefined); + }); + + it('Does add App Start Span spans and measurements longer than threshold in development builds', async () => { + set__DEV__(true); + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockTooLongAppStart(); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toEqual( + expectEventWithStandaloneWarmAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + }); + + it('Does not add App Start Span older than threshold', async () => { + set__DEV__(false); + mockTooOldAppStart(); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toStrictEqual(undefined); + }); + + it('Does add App Start Span older than threshold in development builds', async () => { + set__DEV__(true); + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockTooOldAppStart(); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toEqual( + expectEventWithStandaloneWarmAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + }); + + it('Does not create app start transaction if has_fetched == true', async () => { + mockAppStart({ has_fetched: true }); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toStrictEqual(undefined); + }); + + it('Does not add bundle execution span when bundle start time is missing', async () => { + clearReactNativeBundleExecutionStartTimestamp(); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toStrictEqual(undefined); + }); + + it('Adds bundle execution span', async () => { + _clearRootComponentCreationTimestampMs(); + mockReactNativeBundleExecutionStartTimestamp(); + const [timeOriginMilliseconds] = mockAppStart({ cold: true }); + + const actualEvent = await captureStandAloneAppStart(); + + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const bundleStartSpan = actualEvent!.spans!.find( + ({ description }) => description === 'JS Bundle Execution Start', + ); + + expect(appStartRootSpan).toEqual( + expect.objectContaining({ + description: 'Cold App Start', + span_id: expect.any(String), + op: APP_START_COLD_OP, + }), + ); + expect(bundleStartSpan).toEqual( + expect.objectContaining({ + description: 'JS Bundle Execution Start', + start_timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), + timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), + parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span + op: appStartRootSpan!.op, // op is the same as the root app start span + }), + ); + }); + + it('adds bundle execution before react root', async () => { + mockReactNativeBundleExecutionStartTimestamp(); + const [timeOriginMilliseconds] = mockAppStart({ cold: true }); + setRootComponentCreationTimestampMs(timeOriginMilliseconds - 10); + + const actualEvent = await captureStandAloneAppStart(); + + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const bundleStartSpan = actualEvent!.spans!.find( + ({ description }) => description === 'JS Bundle Execution Before React Root', + ); + + expect(appStartRootSpan).toEqual( + expect.objectContaining({ + description: 'Cold App Start', + span_id: expect.any(String), + op: APP_START_COLD_OP, + }), + ); + expect(bundleStartSpan).toEqual( + expect.objectContaining({ + description: 'JS Bundle Execution Before React Root', + start_timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), + timestamp: (timeOriginMilliseconds - 10) / 1000, + parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span + op: appStartRootSpan!.op, // op is the same as the root app start span + }), + ); + }); + + it('adds native spans as a child of the main app start span', async () => { + const [timeOriginMilliseconds] = mockAppStart({ + cold: true, + enableNativeSpans: true, + }); + + const actualEvent = await captureStandAloneAppStart(); + + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const nativeSpan = actualEvent!.spans!.find(({ description }) => description === 'test native app start span'); + + expect(appStartRootSpan).toEqual( + expect.objectContaining({ + description: 'Cold App Start', + span_id: expect.any(String), + op: APP_START_COLD_OP, + }), + ); + expect(nativeSpan).toEqual( + expect.objectContaining({ + description: 'test native app start span', + start_timestamp: (timeOriginMilliseconds - 100) / 1000, + timestamp: (timeOriginMilliseconds - 50) / 1000, + parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span + op: appStartRootSpan!.op, // op is the same as the root app start span + }), + ); + }); + + it('adds ui kit init full length as a child of the main app start span', async () => { + const timeOriginMilliseconds = Date.now(); + mockAppStart({ + cold: true, + enableNativeSpans: true, + customNativeSpans: [ + { + description: 'UIKit init', // init with lower case is emitted by the native layer + start_timestamp_ms: timeOriginMilliseconds - 100, + end_timestamp_ms: timeOriginMilliseconds - 60, + }, + ], + }); + mockReactNativeBundleExecutionStartTimestamp(); + + const actualEvent = await captureStandAloneAppStart(); + + const nativeSpan = actualEvent!.spans!.find(({ description }) => description?.startsWith('UIKit Init')); + + expect(nativeSpan).toBeDefined(); + expect(nativeSpan).toEqual( + expect.objectContaining({ + description: 'UIKit Init', + start_timestamp: (timeOriginMilliseconds - 100) / 1000, + timestamp: (timeOriginMilliseconds - 60) / 1000, + }), + ); + }); + + it('adds ui kit init start mark as a child of the main app start span', async () => { + const timeOriginMilliseconds = Date.now(); + mockAppStart({ + cold: true, + enableNativeSpans: true, + customNativeSpans: [ + { + description: 'UIKit init', // init with lower case is emitted by the native layer + start_timestamp_ms: timeOriginMilliseconds - 100, + end_timestamp_ms: timeOriginMilliseconds - 20, // After mocked bundle execution start + }, + ], + }); + mockReactNativeBundleExecutionStartTimestamp(); + + const actualEvent = await captureStandAloneAppStart(); + + const nativeRuntimeInitSpan = actualEvent!.spans!.find(({ description }) => + description?.startsWith('UIKit Init to JS Exec Start'), + ); + + expect(nativeRuntimeInitSpan).toBeDefined(); + expect(nativeRuntimeInitSpan).toEqual( + expect.objectContaining({ + description: 'UIKit Init to JS Exec Start', + start_timestamp: (timeOriginMilliseconds - 100) / 1000, + timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), + }), + ); + }); + + it('Does not add app start span twice', async () => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStart({ cold: true }); + + const integration = appStartIntegration({ + standalone: true, + }); + const client = new TestClient({ + ...getDefaultTestClientOptions(), + tracesSampleRate: 1.0, + enableAppStartTracking: true, + }); + setCurrentClient(client); + + integration.setup(client); + await integration.captureStandaloneAppStart(); + const actualEvent = client.event; + expect(actualEvent).toEqual( + expectEventWithStandaloneColdAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + + client.event = undefined; + await integration.captureStandaloneAppStart(); + const secondEvent = client.event; + expect(secondEvent).toBe(undefined); + }); + + it('Does not add app start span when marked as fetched from the native layer', async () => { + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({ + type: 'cold', + has_fetched: true, + spans: [], + }); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toStrictEqual(undefined); + expect(NATIVE.fetchNativeAppStart).toBeCalledTimes(1); + }); + + it('Does not add app start if native returns null', async () => { + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(null); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent).toStrictEqual(undefined); + expect(NATIVE.fetchNativeAppStart).toBeCalledTimes(1); + }); + }); + + describe('App Start Attached to the First Root Span', () => { + it('Does not add App Start Span to Error Event', async () => { + const inputEvent: ErrorEvent = { + type: undefined, + }; + + const actualEvent = await processEvent(inputEvent); + expect(actualEvent).toEqual({ + type: undefined, + }); + }); + + it('Adds Cold App Start Span to Active Span', async () => { + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStart({ cold: true }); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent).toEqual( + expectEventWithAttachedColdAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + }); + + it('Adds Warm App Start Span to Active Span', async () => { + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStart({ cold: false }); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent).toEqual( + expectEventWithAttachedWarmAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + }); + + it('Does not add any spans or measurements when App Start Span is longer than threshold', async () => { + set__DEV__(false); + mockTooLongAppStart(); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent).toStrictEqual(getMinimalTransactionEvent()); + }); + + it('Does add App Start Span spans and measurements longer than threshold in development builds', async () => { + set__DEV__(true); + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockTooLongAppStart(); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent).toEqual( + expectEventWithAttachedWarmAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + }); + + it('Does not add App Start Span older than threshold', async () => { + set__DEV__(false); + mockTooOldAppStart(); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent).toStrictEqual(getMinimalTransactionEvent()); + }); + + it('Does add App Start Span older than threshold in development builds', async () => { + set__DEV__(true); + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockTooOldAppStart(); + + const actualEvent = await processEvent( + getMinimalTransactionEvent({ startTimestampSeconds: timeOriginMilliseconds }), + ); + expect(actualEvent).toEqual( + expectEventWithAttachedWarmAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + }); + + it('Does not create app start transaction if has_fetched == true', async () => { + mockAppStart({ has_fetched: true }); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent).toStrictEqual(getMinimalTransactionEvent()); + }); + + it('Does not add bundle execution span when bundle start time is missing', async () => { + clearReactNativeBundleExecutionStartTimestamp(); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent).toStrictEqual(getMinimalTransactionEvent()); + }); + + it('Adds bundle execution span', async () => { + _clearRootComponentCreationTimestampMs(); + mockReactNativeBundleExecutionStartTimestamp(); + const [timeOriginMilliseconds] = mockAppStart({ cold: true }); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const bundleStartSpan = actualEvent!.spans!.find( + ({ description }) => description === 'JS Bundle Execution Start', + ); + + expect(appStartRootSpan).toEqual( + expect.objectContaining({ + description: 'Cold App Start', + span_id: expect.any(String), + op: APP_START_COLD_OP, + }), + ); + expect(bundleStartSpan).toEqual( + expect.objectContaining({ + description: 'JS Bundle Execution Start', + start_timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), + timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), + parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span + op: appStartRootSpan!.op, // op is the same as the root app start span + }), + ); + }); + + it('adds bundle execution before react root', async () => { + mockReactNativeBundleExecutionStartTimestamp(); + const [timeOriginMilliseconds] = mockAppStart({ cold: true }); + setRootComponentCreationTimestampMs(timeOriginMilliseconds - 10); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const bundleStartSpan = actualEvent!.spans!.find( + ({ description }) => description === 'JS Bundle Execution Before React Root', + ); + + expect(appStartRootSpan).toEqual( + expect.objectContaining({ + description: 'Cold App Start', + span_id: expect.any(String), + op: APP_START_COLD_OP, + }), + ); + expect(bundleStartSpan).toEqual( + expect.objectContaining({ + description: 'JS Bundle Execution Before React Root', + start_timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), + timestamp: (timeOriginMilliseconds - 10) / 1000, + parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span + op: appStartRootSpan!.op, // op is the same as the root app start span + }), + ); + }); + + it('adds native spans as a child of the main app start span', async () => { + const [timeOriginMilliseconds] = mockAppStart({ + cold: true, + enableNativeSpans: true, + }); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const nativeSpan = actualEvent!.spans!.find(({ description }) => description === 'test native app start span'); + + expect(appStartRootSpan).toEqual( + expect.objectContaining({ + description: 'Cold App Start', + span_id: expect.any(String), + op: APP_START_COLD_OP, + }), + ); + expect(nativeSpan).toEqual( + expect.objectContaining({ + description: 'test native app start span', + start_timestamp: (timeOriginMilliseconds - 100) / 1000, + timestamp: (timeOriginMilliseconds - 50) / 1000, + parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span + op: appStartRootSpan!.op, // op is the same as the root app start span + }), + ); + }); + + it('adds ui kit init full length as a child of the main app start span', async () => { + const timeOriginMilliseconds = Date.now(); + mockAppStart({ + cold: true, + enableNativeSpans: true, + customNativeSpans: [ + { + description: 'UIKit init', // init with lower case is emitted by the native layer + start_timestamp_ms: timeOriginMilliseconds - 100, + end_timestamp_ms: timeOriginMilliseconds - 60, + }, + ], + }); + mockReactNativeBundleExecutionStartTimestamp(); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + + const nativeSpan = actualEvent!.spans!.find(({ description }) => description?.startsWith('UIKit Init')); + + expect(nativeSpan).toBeDefined(); + expect(nativeSpan).toEqual( + expect.objectContaining({ + description: 'UIKit Init', + start_timestamp: (timeOriginMilliseconds - 100) / 1000, + timestamp: (timeOriginMilliseconds - 60) / 1000, + }), + ); + }); + + it('adds ui kit init start mark as a child of the main app start span', async () => { + const timeOriginMilliseconds = Date.now(); + mockAppStart({ + cold: true, + enableNativeSpans: true, + customNativeSpans: [ + { + description: 'UIKit init', // init with lower case is emitted by the native layer + start_timestamp_ms: timeOriginMilliseconds - 100, + end_timestamp_ms: timeOriginMilliseconds - 20, // After mocked bundle execution start + }, + ], + }); + mockReactNativeBundleExecutionStartTimestamp(); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + + const nativeRuntimeInitSpan = actualEvent!.spans!.find(({ description }) => + description?.startsWith('UIKit Init to JS Exec Start'), + ); + + expect(nativeRuntimeInitSpan).toBeDefined(); + expect(nativeRuntimeInitSpan).toEqual( + expect.objectContaining({ + description: 'UIKit Init to JS Exec Start', + start_timestamp: (timeOriginMilliseconds - 100) / 1000, + timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), + }), + ); + }); + + it('Does not add app start span twice', async () => { + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStart({ cold: true }); + + const integration = appStartIntegration(); + const client = new TestClient(getDefaultTestClientOptions()); + + const actualEvent = await integration.processEvent(getMinimalTransactionEvent(), {}, client); + expect(actualEvent).toEqual( + expectEventWithAttachedColdAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), + ); + + const secondEvent = await integration.processEvent(getMinimalTransactionEvent(), {}, client); + expect(secondEvent).toStrictEqual(getMinimalTransactionEvent()); + }); + + it('Does not add app start span when marked as fetched from the native layer', async () => { + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({ + type: 'cold', + has_fetched: true, + spans: [], + }); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent).toStrictEqual(getMinimalTransactionEvent()); + expect(NATIVE.fetchNativeAppStart).toBeCalledTimes(1); + }); + + it('Does not add app start if native returns null', async () => { + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(null); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent).toStrictEqual(getMinimalTransactionEvent()); + expect(NATIVE.fetchNativeAppStart).toBeCalledTimes(1); + }); + }); +}); + +function processEvent(event: Event): PromiseLike | Event | null { + const integration = appStartIntegration(); + return integration.processEvent(event, {}, new TestClient(getDefaultTestClientOptions())); +} + +async function captureStandAloneAppStart(): Promise | Event | null> { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + const integration = appStartIntegration({ + standalone: true, + }); + const client = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(client); + integration.setup(client); + await integration.captureStandaloneAppStart(); + + return client.event; +} + +function getMinimalTransactionEvent({ + startTimestampSeconds = 100, +}: { + startTimestampSeconds?: number; +} = {}): TransactionEvent { + return { + type: 'transaction', + start_timestamp: startTimestampSeconds, + contexts: { + trace: { + op: 'test', + span_id: '123', + trace_id: '456', + }, + }, + spans: [ + { + start_timestamp: 100, + timestamp: 200, + op: 'test', + description: 'Test', + span_id: '123', + trace_id: '456', + }, + ], + }; +} + +function expectEventWithAttachedColdAppStart({ + timeOriginMilliseconds, + appStartTimeMilliseconds, +}: { + timeOriginMilliseconds: number; + appStartTimeMilliseconds: number; +}) { + return expect.objectContaining({ + type: 'transaction', + start_timestamp: appStartTimeMilliseconds / 1000, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: UI_LOAD, + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: UI_LOAD, + }), + }), + }), + measurements: expect.objectContaining({ + [APP_START_COLD_MEASUREMENT]: { + value: timeOriginMilliseconds - appStartTimeMilliseconds, + unit: 'millisecond', + }, + }), + spans: expect.arrayContaining([ + { + op: APP_START_COLD_OP, + description: 'Cold App Start', + start_timestamp: appStartTimeMilliseconds / 1000, + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: '123', + origin: 'auto', + status: 'ok', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + }, + { + start_timestamp: 100, + timestamp: 200, + op: 'test', + description: 'Test', + span_id: '123', + trace_id: '456', + }, + ]), + }); +} + +function expectEventWithAttachedWarmAppStart({ + timeOriginMilliseconds, + appStartTimeMilliseconds, +}: { + timeOriginMilliseconds: number; + appStartTimeMilliseconds: number; +}) { + return expect.objectContaining({ + type: 'transaction', + start_timestamp: appStartTimeMilliseconds / 1000, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: UI_LOAD, + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: UI_LOAD, + }), + }), + }), + measurements: expect.objectContaining({ + [APP_START_WARM_MEASUREMENT]: { + value: timeOriginMilliseconds - appStartTimeMilliseconds, + unit: 'millisecond', + }, + }), + spans: expect.arrayContaining([ + { + op: APP_START_WARM_OP, + description: 'Warm App Start', + start_timestamp: appStartTimeMilliseconds / 1000, + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: '123', + origin: 'auto', + status: 'ok', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_WARM_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + }, + { + start_timestamp: 100, + timestamp: 200, + op: 'test', + description: 'Test', + span_id: '123', + trace_id: '456', + }, + ]), + }); +} + +function expectEventWithStandaloneColdAppStart( + actualEvent: Event, + { + timeOriginMilliseconds, + appStartTimeMilliseconds, + }: { + timeOriginMilliseconds: number; + appStartTimeMilliseconds: number; + }, +) { + return expect.objectContaining({ + type: 'transaction', + start_timestamp: appStartTimeMilliseconds / 1000, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: UI_LOAD, + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: UI_LOAD, + }), + }), + }), + measurements: expect.objectContaining({ + [APP_START_COLD_MEASUREMENT]: { + value: timeOriginMilliseconds - appStartTimeMilliseconds, + unit: 'millisecond', + }, + }), + spans: expect.arrayContaining([ + { + op: APP_START_COLD_OP, + description: 'Cold App Start', + start_timestamp: appStartTimeMilliseconds / 1000, + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: actualEvent!.contexts!.trace!.span_id, + origin: 'auto', + status: 'ok', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + }, + ]), + }); +} + +function expectEventWithStandaloneWarmAppStart( + actualEvent: Event, + { + timeOriginMilliseconds, + appStartTimeMilliseconds, + }: { + timeOriginMilliseconds: number; + appStartTimeMilliseconds: number; + }, +) { + return expect.objectContaining({ + type: 'transaction', + start_timestamp: appStartTimeMilliseconds / 1000, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: UI_LOAD, + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: UI_LOAD, + }), + }), + }), + measurements: expect.objectContaining({ + [APP_START_WARM_MEASUREMENT]: { + value: timeOriginMilliseconds - appStartTimeMilliseconds, + unit: 'millisecond', + }, + }), + spans: expect.arrayContaining([ + { + op: APP_START_WARM_OP, + description: 'Warm App Start', + start_timestamp: appStartTimeMilliseconds / 1000, + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: actualEvent!.contexts!.trace!.span_id, + origin: 'auto', + status: 'ok', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_WARM_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + }, + ]), + }); +} + +function mockAppStart({ + cold = false, + has_fetched = false, + enableNativeSpans = false, + customNativeSpans = [], +}: { + cold?: boolean; + has_fetched?: boolean; + enableNativeSpans?: boolean; + customNativeSpans?: NativeAppStartResponse['spans']; +}) { + const timeOriginMilliseconds = Date.now(); + const appStartTimeMilliseconds = timeOriginMilliseconds - 100; + const mockAppStartResponse: NativeAppStartResponse = { + type: cold ? 'cold' : 'warm', + app_start_timestamp_ms: appStartTimeMilliseconds, + has_fetched: has_fetched, + spans: enableNativeSpans + ? [ + { + description: 'test native app start span', + start_timestamp_ms: timeOriginMilliseconds - 100, + end_timestamp_ms: timeOriginMilliseconds - 50, + }, + ...customNativeSpans, + ] + : [], + }; + + _setAppStartEndTimestampMs(timeOriginMilliseconds); + mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); + + return [timeOriginMilliseconds, appStartTimeMilliseconds]; +} + +function mockTooLongAppStart() { + const timeOriginMilliseconds = Date.now(); + const appStartTimeMilliseconds = timeOriginMilliseconds - 65000; + const mockAppStartResponse: NativeAppStartResponse = { + type: 'warm', + app_start_timestamp_ms: appStartTimeMilliseconds, + has_fetched: false, + spans: [], + }; + + _setAppStartEndTimestampMs(timeOriginMilliseconds); + mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); + + return [timeOriginMilliseconds, appStartTimeMilliseconds]; +} + +function mockTooOldAppStart() { + const timeOriginMilliseconds = Date.now(); + const appStartTimeMilliseconds = timeOriginMilliseconds - 65000; + const mockAppStartResponse: NativeAppStartResponse = { + type: 'warm', + app_start_timestamp_ms: appStartTimeMilliseconds, + has_fetched: false, + spans: [], + }; + + // App start finish timestamp + _setAppStartEndTimestampMs(timeOriginMilliseconds); + mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds - 64000); + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); + // Transaction start timestamp + mockFunction(timestampInSeconds).mockReturnValue(timeOriginMilliseconds / 1000 + 65); + + return [timeOriginMilliseconds, appStartTimeMilliseconds]; +} + +/** + * Mocks RN Bundle Start Module + * `var __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now()` + */ +function mockReactNativeBundleExecutionStartTimestamp() { + RN_GLOBAL_OBJ.nativePerformanceNow = () => 100; // monotonic clock like `performance.now()` + RN_GLOBAL_OBJ.__BUNDLE_START_TIME__ = 50; // 50ms after time origin +} + +/** + * Removes mock added by mockReactNativeBundleExecutionStartTimestamp + */ +function clearReactNativeBundleExecutionStartTimestamp() { + delete RN_GLOBAL_OBJ.nativePerformanceNow; + delete RN_GLOBAL_OBJ.__BUNDLE_START_TIME__; +} + +function set__DEV__(value: boolean) { + Object.defineProperty(globalThis, '__DEV__', { + value, + writable: true, + }); +} diff --git a/test/tracing/reactnativenavigation.test.ts b/test/tracing/reactnativenavigation.test.ts index b12e86a170..44c3a1abae 100644 --- a/test/tracing/reactnativenavigation.test.ts +++ b/test/tracing/reactnativenavigation.test.ts @@ -372,13 +372,13 @@ describe('React Native Navigation Instrumentation', () => { routingInstrumentation: rNavigation, enableStallTracking: false, enableNativeFramesTracking: false, - enableAppStartTracking: false, beforeNavigate: setupOptions.beforeNavigate, }); const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, integrations: [rnTracing], + enableAppStartTracking: false, }); client = new TestClient(options); setCurrentClient(client); diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index bcd49bbda7..f01d049b60 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -8,9 +8,8 @@ jest.mock('@sentry/utils', () => { }); import * as SentryBrowser from '@sentry/browser'; -import type { Event, Span, SpanJSON } from '@sentry/types'; +import type { Event, Span } from '@sentry/types'; -import type { NativeAppStartResponse } from '../../src/js/NativeRNSentry'; import { RoutingInstrumentation } from '../../src/js/tracing/routingInstrumentation'; jest.mock('../../src/js/wrapper', () => { @@ -59,33 +58,17 @@ jest.mock('react-native/Libraries/AppState/AppState', () => mockedAppState); import { getActiveSpan, spanToJSON, startSpanManual } from '@sentry/browser'; import { getCurrentScope, SPAN_STATUS_ERROR, startInactiveSpan } from '@sentry/core'; -import { timestampInSeconds } from '@sentry/utils'; import type { AppState, AppStateStatus } from 'react-native'; -import { APP_START_COLD, APP_START_WARM } from '../../src/js/measurements'; -import { - APP_START_COLD as APP_START_COLD_OP, - APP_START_WARM as APP_START_WARM_OP, - UI_LOAD, -} from '../../src/js/tracing'; -import { APP_START_WARM as APP_SPAN_START_WARM } from '../../src/js/tracing/ops'; import { ReactNativeTracing } from '../../src/js/tracing/reactnativetracing'; -import { getTimeOriginMilliseconds } from '../../src/js/tracing/utils'; -import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { NATIVE } from '../../src/js/wrapper'; import type { TestClient } from '../mocks/client'; import { setupTestClient } from '../mocks/client'; -import { mockFunction } from '../testutils'; import type { MockedRoutingInstrumentation } from './mockedrountinginstrumention'; import { createMockedRoutingInstrumentation } from './mockedrountinginstrumention'; -const originalTimestampInSeconds = mockFunction(timestampInSeconds).getMockImplementation(); - -const DEFAULT_IDLE_TIMEOUT = 1000; - describe('ReactNativeTracing', () => { beforeEach(() => { - clearReactNativeBundleExecutionStartTimestamp(); jest.useFakeTimers(); NATIVE.enableNative = true; mockedAppState.isAvailable = true; @@ -169,432 +152,13 @@ describe('ReactNativeTracing', () => { }); }); - describe('App Start Tracing Instrumentation', () => { + describe('Tracing Instrumentation', () => { let client: TestClient; beforeEach(() => { client = setupTestClient(); }); - describe('App Start without routing instrumentation', () => { - it('Starts route transaction (cold)', async () => { - const integration = new ReactNativeTracing({ - enableNativeFramesTracking: false, - }); - - const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: true }); - - integration.setup(client); - integration.onAppStartFinish(Date.now() / 1000); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - expect(transaction).toBeDefined(); - expect(transaction?.start_timestamp).toBe(appStartTimeMilliseconds / 1000); - expect(transaction?.contexts?.trace?.op).toBe(UI_LOAD); - - expect(transaction?.measurements?.[APP_START_COLD].value).toEqual( - timeOriginMilliseconds - appStartTimeMilliseconds, - ); - expect(transaction?.measurements?.[APP_START_COLD].unit).toBe('millisecond'); - }); - - it('Starts route transaction (warm)', async () => { - const integration = new ReactNativeTracing(); - - const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false }); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - expect(transaction).toBeDefined(); - expect(transaction?.start_timestamp).toBe(appStartTimeMilliseconds / 1000); - expect(transaction?.contexts?.trace?.op).toBe(UI_LOAD); - - expect(transaction?.measurements?.[APP_START_WARM].value).toEqual( - timeOriginMilliseconds - appStartTimeMilliseconds, - ); - expect(transaction?.measurements?.[APP_START_WARM].unit).toBe('millisecond'); - }); - - it('Cancels route transaction when app goes to background', async () => { - const integration = new ReactNativeTracing(); - - mockAppStartResponse({ cold: false }); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - - mockedAppState.setState('background'); - jest.runAllTimers(); - - const transaction = client.event; - expect(transaction?.contexts?.trace?.status).toBe('cancelled'); - expect(mockedAppState.removeSubscription).toBeCalledTimes(1); - }); - - it('Does not crash when AppState is not available', async () => { - mockedAppState.isAvailable = false; - mockedAppState.addEventListener = ((): void => { - return undefined; - }) as unknown as (typeof mockedAppState)['addEventListener']; // RN Web can return undefined - - setupTestClient({ - integrations: [new ReactNativeTracing()], - }); - - mockAppStartResponse({ cold: false }); - - await jest.advanceTimersByTimeAsync(500); - const transaction = getActiveSpan(); - - jest.runAllTimers(); - - expect(spanToJSON(transaction!).timestamp).toBeDefined(); - }); - - it('Does not add app start measurement if more than 60s', async () => { - const integration = new ReactNativeTracing(); - - const timeOriginMilliseconds = Date.now(); - const appStartTimeMilliseconds = timeOriginMilliseconds - 65000; - const mockAppStartResponse: NativeAppStartResponse = { - type: 'warm', - app_start_timestamp_ms: appStartTimeMilliseconds, - has_fetched: false, - spans: [], - }; - - mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); - mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - expect(transaction).toBeDefined(); - expect(transaction?.measurements?.[APP_START_WARM]).toBeUndefined(); - expect(transaction?.measurements?.[APP_START_COLD]).toBeUndefined(); - }); - - it('Does not add app start span if more than 60s', async () => { - const integration = new ReactNativeTracing(); - - const timeOriginMilliseconds = Date.now(); - const appStartTimeMilliseconds = timeOriginMilliseconds - 65000; - const mockAppStartResponse: NativeAppStartResponse = { - type: 'warm', - app_start_timestamp_ms: appStartTimeMilliseconds, - has_fetched: false, - spans: [], - }; - - mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); - mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - expect(transaction).toBeDefined(); - expect(transaction?.spans?.some(span => span.op == APP_SPAN_START_WARM)).toBeFalse(); - expect(transaction?.start_timestamp).toBeGreaterThanOrEqual(timeOriginMilliseconds / 1000); - }); - - describe('old app starts', () => { - let integration: ReactNativeTracing; - let timeOriginMilliseconds: number; - - beforeEach(() => { - integration = new ReactNativeTracing(); - - timeOriginMilliseconds = Date.now(); - const appStartTimeMilliseconds = timeOriginMilliseconds - 65000; - const mockAppStartResponse: NativeAppStartResponse = { - type: 'warm', - app_start_timestamp_ms: appStartTimeMilliseconds, - has_fetched: false, - spans: [], - }; - - // App start finish timestamp - mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds - 64000); - mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); - // Transaction start timestamp - mockFunction(timestampInSeconds).mockReturnValue(timeOriginMilliseconds / 1000 + 65); - }); - - afterEach(async () => { - mockFunction(timestampInSeconds).mockReset().mockImplementation(originalTimestampInSeconds); - set__DEV__(true); - }); - - it('Does not add app start span older than than 60s in production', async () => { - set__DEV__(false); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - expect(transaction).toBeDefined(); - expect(transaction?.spans?.some(span => span.op == APP_SPAN_START_WARM)).toBeFalse(); - expect(transaction?.start_timestamp).toBeGreaterThanOrEqual(timeOriginMilliseconds / 1000); - }); - - it('Does add app start span older than than 60s in development builds', async () => { - set__DEV__(true); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - expect(transaction).toBeDefined(); - expect(transaction?.spans?.some(span => span.op == APP_SPAN_START_WARM)).toBeTrue(); - expect(transaction?.start_timestamp).toBeGreaterThanOrEqual((timeOriginMilliseconds - 65000) / 1000); - }); - }); - - it('Does not create app start transaction if has_fetched == true', async () => { - const integration = new ReactNativeTracing(); - - mockAppStartResponse({ cold: false, has_fetched: true }); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - expect(transaction).toBeUndefined(); - }); - - describe('bundle execution spans', () => { - it('does not add bundle executions span if __BUNDLE_START_TIME__ is undefined', async () => { - const integration = new ReactNativeTracing(); - - mockAppStartResponse({ cold: true }); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - const bundleStartSpan = transaction!.spans!.find( - ({ description }) => - description === 'JS Bundle Execution Start' || description === 'JS Bundle Execution Before React Root', - ); - - expect(bundleStartSpan).toBeUndefined(); - }); - - it('adds bundle execution span', async () => { - const integration = new ReactNativeTracing(); - - const [timeOriginMilliseconds] = mockAppStartResponse({ cold: true }); - mockReactNativeBundleExecutionStartTimestamp(); - - integration.setup(client); - integration.onAppStartFinish(timeOriginMilliseconds + 200); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - const appStartRootSpan = transaction!.spans!.find(({ description }) => description === 'Cold App Start'); - const bundleStartSpan = transaction!.spans!.find( - ({ description }) => description === 'JS Bundle Execution Start', - ); - - expect(appStartRootSpan).toEqual( - expect.objectContaining({ - description: 'Cold App Start', - span_id: expect.any(String), - op: APP_START_COLD_OP, - }), - ); - expect(bundleStartSpan).toEqual( - expect.objectContaining({ - description: 'JS Bundle Execution Start', - start_timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), - timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), - parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span - op: appStartRootSpan!.op, // op is the same as the root app start span - }), - ); - }); - - it('adds bundle execution before react root', async () => { - const integration = new ReactNativeTracing(); - - const [timeOriginMilliseconds] = mockAppStartResponse({ cold: true }); - mockReactNativeBundleExecutionStartTimestamp(); - - integration.setup(client); - integration.setRootComponentFirstConstructorCallTimestampMs(timeOriginMilliseconds - 10); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - const appStartRootSpan = transaction!.spans!.find(({ description }) => description === 'Cold App Start'); - const bundleStartSpan = transaction!.spans!.find( - ({ description }) => description === 'JS Bundle Execution Before React Root', - ); - - expect(appStartRootSpan).toEqual( - expect.objectContaining({ - description: 'Cold App Start', - span_id: expect.any(String), - op: APP_START_COLD_OP, - }), - ); - expect(bundleStartSpan).toEqual( - expect.objectContaining({ - description: 'JS Bundle Execution Before React Root', - start_timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), - timestamp: (timeOriginMilliseconds - 10) / 1000, - parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span - op: appStartRootSpan!.op, // op is the same as the root app start span - }), - ); - }); - }); - - it('adds native spans as a child of the main app start span', async () => { - const integration = new ReactNativeTracing(); - - const [timeOriginMilliseconds] = mockAppStartResponse({ - cold: true, - enableNativeSpans: true, - }); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - const appStartRootSpan = transaction!.spans!.find(({ description }) => description === 'Cold App Start'); - const nativeSpan = transaction!.spans!.find(({ description }) => description === 'test native app start span'); - - expect(appStartRootSpan).toEqual( - expect.objectContaining({ - description: 'Cold App Start', - span_id: expect.any(String), - op: APP_START_COLD_OP, - }), - ); - expect(nativeSpan).toEqual( - expect.objectContaining({ - description: 'test native app start span', - start_timestamp: (timeOriginMilliseconds - 100) / 1000, - timestamp: (timeOriginMilliseconds - 50) / 1000, - parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span - op: appStartRootSpan!.op, // op is the same as the root app start span - }), - ); - }); - - it('adds ui kit init full length as a child of the main app start span', async () => { - const integration = new ReactNativeTracing(); - - const timeOriginMilliseconds = Date.now(); - mockAppStartResponse({ - cold: true, - enableNativeSpans: true, - customNativeSpans: [ - { - description: 'UIKit init', - start_timestamp_ms: timeOriginMilliseconds - 100, - end_timestamp_ms: timeOriginMilliseconds - 60, - }, - ], - }); - mockReactNativeBundleExecutionStartTimestamp(); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - const nativeSpan = transaction!.spans!.find(({ description }) => description?.startsWith('UIKit Init')); - - expect(nativeSpan).toBeDefined(); - expect(nativeSpan).toEqual( - expect.objectContaining({ - description: 'UIKit Init', - start_timestamp: (timeOriginMilliseconds - 100) / 1000, - timestamp: (timeOriginMilliseconds - 60) / 1000, - }), - ); - }); - - it('adds ui kit init start mark as a child of the main app start span', async () => { - const integration = new ReactNativeTracing(); - - const timeOriginMilliseconds = Date.now(); - mockAppStartResponse({ - cold: true, - enableNativeSpans: true, - customNativeSpans: [ - { - description: 'UIKit init', - start_timestamp_ms: timeOriginMilliseconds - 100, - end_timestamp_ms: timeOriginMilliseconds - 20, // After mocked bundle execution start - }, - ], - }); - mockReactNativeBundleExecutionStartTimestamp(); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - - const nativeRuntimeInitSpan = transaction!.spans!.find(({ description }) => - description?.startsWith('UIKit Init to JS Exec Start'), - ); - - expect(nativeRuntimeInitSpan).toBeDefined(); - expect(nativeRuntimeInitSpan).toEqual( - expect.objectContaining({ - description: 'UIKit Init to JS Exec Start', - start_timestamp: (timeOriginMilliseconds - 100) / 1000, - timestamp: (timeOriginMilliseconds - 50) / 1000, - }), - ); - }); - }); - describe('With routing instrumentation', () => { it('Cancels route transaction when app goes to background', async () => { const routingInstrumentation = new RoutingInstrumentation(); @@ -602,8 +166,6 @@ describe('ReactNativeTracing', () => { routingInstrumentation, }); - mockAppStartResponse({ cold: true }); - integration.setup(client); // wait for internal promises to resolve, fetch app start data from mocked native await Promise.resolve(); @@ -621,160 +183,32 @@ describe('ReactNativeTracing', () => { expect(mockedAppState.removeSubscription).toBeCalledTimes(1); }); - it('Adds measurements and child span onto existing routing transaction and sets the op (cold)', async () => { - const routingInstrumentation = new RoutingInstrumentation(); - const integration = new ReactNativeTracing({ - routingInstrumentation, - }); - - const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: true }); - - integration.setup(client); - // wait for internal promises to resolve, fetch app start data from mocked native - await Promise.resolve(); - - expect(getActiveSpan()).toBeUndefined(); - - routingInstrumentation.onRouteWillChange({ - name: 'test', - }); - - expect(getActiveSpan()).toBeDefined(); - expect(spanToJSON(getActiveSpan()!).description).toEqual('Route Change'); - - // trigger idle transaction to finish and call before finish callbacks - jest.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT); - jest.runOnlyPendingTimers(); - - const routeTransactionEvent = client.event; - expect(routeTransactionEvent!.measurements![APP_START_COLD].value).toBe( - timeOriginMilliseconds - appStartTimeMilliseconds, - ); - - expect(routeTransactionEvent!.contexts!.trace!.op).toBe(UI_LOAD); - expect(routeTransactionEvent!.start_timestamp).toBe(appStartTimeMilliseconds / 1000); - - const span = routeTransactionEvent!.spans![routeTransactionEvent!.spans!.length - 1]; - expect(span!.op).toBe(APP_START_COLD_OP); - expect(span!.description).toBe('Cold App Start'); - expect(span!.start_timestamp).toBe(appStartTimeMilliseconds / 1000); - expect(span!.timestamp).toBe(timeOriginMilliseconds / 1000); - }); - - it('Adds measurements and child span onto existing routing transaction and sets the op (warm)', async () => { - const routingInstrumentation = new RoutingInstrumentation(); - const integration = new ReactNativeTracing({ - routingInstrumentation, - }); - - const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false }); - - integration.setup(client); - // wait for internal promises to resolve, fetch app start data from mocked native - await Promise.resolve(); - - expect(getActiveSpan()).toBeUndefined(); - - routingInstrumentation.onRouteWillChange({ - name: 'test', - }); - - expect(getActiveSpan()).toBeDefined(); - expect(spanToJSON(getActiveSpan()!).description).toEqual('Route Change'); - - // trigger idle transaction to finish and call before finish callbacks - jest.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT); - jest.runOnlyPendingTimers(); - - const routeTransaction = client.event; - expect(routeTransaction!.measurements![APP_START_WARM].value).toBe( - timeOriginMilliseconds - appStartTimeMilliseconds, - ); - - expect(routeTransaction!.contexts!.trace!.op).toBe(UI_LOAD); - expect(routeTransaction!.start_timestamp).toBe(appStartTimeMilliseconds / 1000); - - const span = routeTransaction!.spans![routeTransaction!.spans!.length - 1]; - expect(span!.op).toBe(APP_START_WARM_OP); - expect(span!.description).toBe('Warm App Start'); - expect(span!.start_timestamp).toBe(appStartTimeMilliseconds / 1000); - expect(span!.timestamp).toBe(timeOriginMilliseconds / 1000); - }); + it('Does not crash when AppState is not available', async () => { + mockedAppState.isAvailable = false; + mockedAppState.addEventListener = ((): void => { + return undefined; + }) as unknown as (typeof mockedAppState)['addEventListener']; // RN Web can return undefined - it('Does not update route transaction if has_fetched == true', async () => { const routingInstrumentation = new RoutingInstrumentation(); - const integration = new ReactNativeTracing({ - enableStallTracking: false, - routingInstrumentation, + setupTestClient({ + integrations: [ + new ReactNativeTracing({ + routingInstrumentation, + }), + ], }); - const [, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false, has_fetched: true }); - - integration.setup(client); - // wait for internal promises to resolve, fetch app start data from mocked native - await Promise.resolve(); - - expect(getActiveSpan()).toBeUndefined(); - routingInstrumentation.onRouteWillChange({ name: 'test', }); - expect(getActiveSpan()).toBeDefined(); - expect(spanToJSON(getActiveSpan()!).description).toEqual('Route Change'); - - // trigger idle transaction to finish and call before finish callbacks - jest.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT); - jest.runOnlyPendingTimers(); + await jest.advanceTimersByTimeAsync(500); + const transaction = getActiveSpan(); - const routeTransaction = client.event; - expect(routeTransaction!.measurements).toBeUndefined(); - expect(routeTransaction!.contexts!.trace!.op).not.toBe(UI_LOAD); - expect(routeTransaction!.start_timestamp).not.toBe(appStartTimeMilliseconds / 1000); - expect(routeTransaction!.spans!.length).toBe(0); - }); - }); + jest.runAllTimers(); - it('Does not instrument app start if app start is disabled', async () => { - const integration = new ReactNativeTracing({ - enableAppStartTracking: false, + expect(spanToJSON(transaction!).timestamp).toBeDefined(); }); - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - expect(transaction).toBeUndefined(); - expect(NATIVE.fetchNativeAppStart).not.toBeCalled(); - }); - - it('Does not instrument app start if native is disabled', async () => { - NATIVE.enableNative = false; - - const integration = new ReactNativeTracing(); - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - expect(transaction).toBeUndefined(); - expect(NATIVE.fetchNativeAppStart).not.toBeCalled(); - }); - - it('Does not instrument app start if fetchNativeAppStart returns null', async () => { - mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(null); - - const integration = new ReactNativeTracing(); - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - expect(transaction).toBeUndefined(); - expect(NATIVE.fetchNativeAppStart).toBeCalledTimes(1); }); }); @@ -1166,62 +600,3 @@ describe('ReactNativeTracing', () => { }); }); }); - -function mockAppStartResponse({ - cold, - has_fetched, - enableNativeSpans, - customNativeSpans, -}: { - cold: boolean; - has_fetched?: boolean; - enableNativeSpans?: boolean; - customNativeSpans?: NativeAppStartResponse['spans']; -}) { - const timeOriginMilliseconds = Date.now(); - const appStartTimeMilliseconds = timeOriginMilliseconds - 100; - const mockAppStartResponse: NativeAppStartResponse = { - type: cold ? 'cold' : 'warm', - app_start_timestamp_ms: appStartTimeMilliseconds, - has_fetched: has_fetched ?? false, - spans: enableNativeSpans - ? [ - { - description: 'test native app start span', - start_timestamp_ms: timeOriginMilliseconds - 100, - end_timestamp_ms: timeOriginMilliseconds - 50, - }, - ...(customNativeSpans ?? []), - ] - : [], - }; - - mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); - mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); - - return [timeOriginMilliseconds, appStartTimeMilliseconds]; -} - -/** - * Mocks RN Bundle Start Module - * `var __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now()` - */ -function mockReactNativeBundleExecutionStartTimestamp() { - RN_GLOBAL_OBJ.nativePerformanceNow = () => 100; // monotonic clock like `performance.now()` - RN_GLOBAL_OBJ.__BUNDLE_START_TIME__ = 50; // 50ms after time origin -} - -/** - * Removes mock added by mockReactNativeBundleExecutionStartTimestamp - */ -function clearReactNativeBundleExecutionStartTimestamp() { - delete RN_GLOBAL_OBJ.nativePerformanceNow; - delete RN_GLOBAL_OBJ.__BUNDLE_START_TIME__; -} - -function set__DEV__(value: boolean) { - Object.defineProperty(globalThis, '__DEV__', { - value, - writable: true, - }); -} diff --git a/test/tracing/reactnavigation.stalltracking.test.ts b/test/tracing/reactnavigation.stalltracking.test.ts index c025de0652..b8a041aa06 100644 --- a/test/tracing/reactnavigation.stalltracking.test.ts +++ b/test/tracing/reactnavigation.stalltracking.test.ts @@ -32,12 +32,12 @@ describe('StallTracking with ReactNavigation', () => { routingInstrumentation: rnavigation, enableStallTracking: true, enableNativeFramesTracking: false, - enableAppStartTracking: false, }); const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, integrations: [rnTracing], + enableAppStartTracking: false, }); client = new TestClient(options); setCurrentClient(client); diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index cd52e72a70..1ba51d5d3f 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -335,13 +335,13 @@ describe('ReactNavigationInstrumentation', () => { routingInstrumentation: rNavigation, enableStallTracking: false, enableNativeFramesTracking: false, - enableAppStartTracking: false, beforeNavigate: setupOptions.beforeNavigate, }); const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, integrations: [rnTracing], + enableAppStartTracking: false, }); client = new TestClient(options); setCurrentClient(client); diff --git a/test/tracing/reactnavigation.ttid.test.tsx b/test/tracing/reactnavigation.ttid.test.tsx index 07b6cb267e..f82996c6e0 100644 --- a/test/tracing/reactnavigation.ttid.test.tsx +++ b/test/tracing/reactnavigation.ttid.test.tsx @@ -14,6 +14,7 @@ import TestRenderer from 'react-test-renderer'; import * as Sentry from '../../src/js'; import { ReactNavigationInstrumentation } from '../../src/js'; import { TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing'; +import { _setAppStartEndTimestampMs } from '../../src/js/tracing/integrations/appStart'; import { isHermesEnabled, notWeb } from '../../src/js/utils/environment'; import { createSentryEventEmitter } from '../../src/js/utils/sentryeventemitter'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; @@ -41,6 +42,7 @@ describe('React Navigation - TTID', () => { type: 'cold', spans: [], }); + _setAppStartEndTimestampMs(mockedAppStartTimeSeconds * 1000); mockedEventEmitter = mockedSentryEventEmitter.createMockedSentryEventEmitter(); (createSentryEventEmitter as jest.Mock).mockReturnValue(mockedEventEmitter); @@ -540,6 +542,7 @@ function initSentry(sut: ReactNavigationInstrumentation): { send: transportSendMock.mockResolvedValue({}), flush: jest.fn().mockResolvedValue(true), }), + enableAppStartTracking: true, }; Sentry.init(options); diff --git a/test/tracing/stalltracking.test.ts b/test/tracing/stalltracking.test.ts index af1097566e..f0042a5af8 100644 --- a/test/tracing/stalltracking.test.ts +++ b/test/tracing/stalltracking.test.ts @@ -38,12 +38,12 @@ describe('StallTracking', () => { const rnTracing = new ReactNativeTracing({ enableStallTracking: true, enableNativeFramesTracking: false, - enableAppStartTracking: false, }); const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, integrations: [rnTracing], + enableAppStartTracking: false, }); client = new TestClient(options); setCurrentClient(client);