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);