From c3dcfd3cc59413bf47ca96657f3543eda9fe41fb Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 3 Jun 2024 18:39:03 +0200 Subject: [PATCH 01/14] chore(tracing): extract app start to a standalone integration --- src/js/sdk.tsx | 7 +- src/js/tracing/integrations/appStart.ts | 164 ++++++++++++++++++++++++ src/js/tracing/reactnativeprofiler.tsx | 8 +- src/js/tracing/reactnativetracing.ts | 136 +++----------------- 4 files changed, 185 insertions(+), 130 deletions(-) create mode 100644 src/js/tracing/integrations/appStart.ts diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index 07576d1f9e..c54322aebe 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -13,8 +13,8 @@ import { getDefaultIntegrations } from './integrations/default'; import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options'; import { shouldEnableNativeNagger } from './options'; import { TouchEventBoundary } from './touchevents'; -import type { ReactNativeTracing } from './tracing'; import { ReactNativeProfiler } from './tracing'; +import { useAppStartFromSentryRNPProfiler } from './tracing/integrations/appStart'; import { useEncodePolyfill } from './transports/encodePolyfill'; import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native'; import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer } from './utils/environment'; @@ -106,10 +106,7 @@ export function wrap

>( RootComponent: React.ComponentType

, options?: ReactNativeWrapperOptions ): React.ComponentType

{ - const tracingIntegration = getClient()?.getIntegrationByName?.('ReactNativeTracing') as ReactNativeTracing | undefined; - if (tracingIntegration) { - tracingIntegration.useAppStartWithProfiler = true; - } + useAppStartFromSentryRNPProfiler(); const profilerProps = { ...(options?.profilerProps ?? {}), diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts new file mode 100644 index 0000000000..c309b85b8c --- /dev/null +++ b/src/js/tracing/integrations/appStart.ts @@ -0,0 +1,164 @@ +import type { Event, Integration, SpanJSON, TransactionEvent } from '@sentry/types'; +import { logger, uuid4 } from '@sentry/utils'; + +import { + APP_START_COLD as APP_START_COLD_MEASUREMENT, + APP_START_WARM as APP_START_WARM_MEASUREMENT, +} from '../../measurements'; +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 { getTimeOriginMilliseconds } from '../utils'; + +const INTEGRATION_NAME = 'AppStart'; + +/** + * 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 = 60000; + +let useAppStartEndFromSentryRNProfiler = false; +let appStartEndTimestampMs: number | undefined = undefined; + +/** + * Records the application start end. + */ +export const setAppStartEndTimestamp = (timestamp: number): void => { + appStartEndTimestampMs && logger.warn('Overwriting already set app start.'); + appStartEndTimestampMs = timestamp; +}; + +/** + * Sets the App Start integration to use the application start end from the Sentry React Native Profiler. + */ +export const useAppStartFromSentryRNPProfiler = (): void => { + useAppStartEndFromSentryRNProfiler = true; +}; + +/** + * Adds AppStart spans from the native layer to the transaction event. + */ +export const appStartIntegration = (): Integration => { + let appStartDataFlushed = false; + + const setup = (): void => { + if (!useAppStartEndFromSentryRNProfiler) { + appStartEndTimestampMs = getTimeOriginMilliseconds(); + } + }; + + const processEvent = async (event: Event): Promise => { + if (appStartDataFlushed) { + return event; + } + + if (event.type !== 'transaction') { + // App start data is only relevant for transactions + return event; + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + attachAppStartToTransactionEvent(event as TransactionEvent); + + return event; + }; + + async function attachAppStartToTransactionEvent(event: TransactionEvent): Promise { + if (!event.contexts || !event.contexts.trace) { + logger.warn('Transaction event is missing trace context. Can not attach app start.'); + return; + } + + event.contexts.trace.data = event.contexts.trace.data || {}; + event.contexts.trace.data['SEMANTIC_ATTRIBUTE_SENTRY_OP'] = UI_LOAD_OP; + + const appStart = await NATIVE.fetchNativeAppStart(); + + if (!appStart) { + logger.warn('Failed to retrieve the app start metrics from the native layer.'); + return; + } + + if (appStart.didFetchAppStart) { + logger.warn('Measured app start metrics were already reported from the native layer.'); + return; + } + + if (!appStartEndTimestampMs) { + logger.warn('Javascript failed to record app start end.'); + return; + } + + const appStartDurationMs = appStartEndTimestampMs - appStart.appStartTime; + + if (appStartDurationMs >= MAX_APP_START_DURATION_MS) { + return; + } + + appStartDataFlushed = true; + + const appStartTimestampSeconds = appStart.appStartTime / 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 op = appStart.isColdStart ? APP_START_COLD_OP : APP_START_WARM_OP; + + children.push({ + description: appStart.isColdStart ? 'Cold App Start' : 'Warm App Start', + op, + start_timestamp: appStartTimestampSeconds, + timestamp: appStartEndTimestampMs / 1000, + trace_id: event.contexts.trace.trace_id, + span_id: uuid4(), + }); + const measurement = appStart.isColdStart ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT; + + event.measurements = event.measurements || {}; + event.measurements[measurement] = { + value: appStartDurationMs, + unit: 'millisecond', + }; + } + + return { + name: INTEGRATION_NAME, + setupOnce: () => { + // noop + }, + setup, + processEvent, + }; +}; + +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', + }; +} diff --git a/src/js/tracing/reactnativeprofiler.tsx b/src/js/tracing/reactnativeprofiler.tsx index a6d15deddf..cf23d760f7 100644 --- a/src/js/tracing/reactnativeprofiler.tsx +++ b/src/js/tracing/reactnativeprofiler.tsx @@ -2,7 +2,7 @@ import { spanToJSON } from '@sentry/core'; import { getClient, Profiler } from '@sentry/react'; import { createIntegration } from '../integrations/factory'; -import type { ReactNativeTracing } from './reactnativetracing'; +import { setAppStartEndTimestamp } from '../tracing/integrations/appStart'; const ReactNativeProfilerGlobalState = { appStartReported: false, @@ -41,10 +41,6 @@ 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); + typeof endTimestamp === 'number' && setAppStartEndTimestamp(endTimestamp); } } diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 2ed34dbf0b..106be74795 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -4,22 +4,16 @@ import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from import { getActiveSpan, getCurrentScope, - getSpanDescendants, SEMANTIC_ATTRIBUTE_SENTRY_OP, SentryNonRecordingSpan, - setMeasurement, SPAN_STATUS_ERROR, spanToJSON, startIdleSpan, - startInactiveSpan, } 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 { @@ -29,10 +23,9 @@ 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 { UI_LOAD } from './ops'; import { StallTrackingInstrumentation } from './stalltracking'; import type { BeforeNavigate } from './types'; -import { getTimeOriginMilliseconds, setSpanDurationAsMeasurement } from './utils'; const SCOPE_SPAN_FIELD = '_sentrySpan'; @@ -147,8 +140,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; + /** * @inheritDoc */ @@ -162,8 +154,7 @@ 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; @@ -222,9 +213,7 @@ export class ReactNativeTracing implements Integration { DEFAULT_TRACE_PROPAGATION_TARGETS; if (enableAppStartTracking) { - this._instrumentAppStart().then(undefined, (reason: unknown) => { - logger.error(`[ReactNativeTracing] Error while instrumenting app start:`, reason); - }); + this._instrumentAppStart(); } this._enableNativeFramesTracking(client); @@ -264,12 +253,12 @@ export class ReactNativeTracing implements Integration { : eventWithView; } - /** - * Called by the ReactNativeProfiler component on first component mount. - */ - public onAppStartFinish(endTimestamp: number): void { - this._appStartFinishTimestamp = endTimestamp; - } + // /** + // * Called by the ReactNativeProfiler component on first component mount. + // */ + // public onAppStartFinish(endTimestamp: number): void { + // this._appStartFinishTimestamp = endTimestamp; + // } /** * Starts a new transaction for a user interaction. @@ -389,96 +378,19 @@ 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(appStart: NativeAppStartResponse): number | undefined { - if (!this._appStartFinishTimestamp) { - return undefined; - } - return this._appStartFinishTimestamp * 1000 - appStart.appStartTime; - } - /** * Instruments the app start measurements on the first route transaction. * Starts a route transaction if there isn't routing instrumentation. */ - private async _instrumentAppStart(): Promise { - if (!this.options.enableAppStartTracking || !NATIVE.enableNative) { - return; - } - - const appStart = await NATIVE.fetchNativeAppStart(); - - if (!appStart || appStart.didFetchAppStart) { - return; - } - - if (!this.useAppStartWithProfiler) { - 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 { - if (!isSentrySpan(span)) { + private _instrumentAppStart(): void { + if (!this.options.enableAppStartTracking || !NATIVE.enableNative || this.options.routingInstrumentation) { return; } - const appStartDurationMilliseconds = this._getAppStartDurationMilliseconds(appStart); - if (!appStartDurationMilliseconds) { - logger.warn('App start was never finished.'); - 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) { - return; - } - - const appStartTimeSeconds = appStart.appStartTime / 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.isColdStart ? APP_START_COLD_OP : APP_START_WARM_OP; - startInactiveSpan({ - name: appStart.isColdStart ? 'Cold App Start' : 'Warm App Start', - op, - startTime: appStartTimeSeconds, - }).end(this._appStartFinishTimestamp); - const measurement = appStart.isColdStart ? APP_START_COLD : APP_START_WARM; - setMeasurement(measurement, appStartDurationMilliseconds, 'millisecond'); + this._createRouteTransaction({ + name: 'App Start', + op: UI_LOAD, + }); } /** To be called when the route changes, but BEFORE the components of the new route mount. */ @@ -526,21 +438,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; } From fe53af595ce39ecc4db9eb5bdb641e52b9f8658c Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 11 Jun 2024 16:34:43 +0200 Subject: [PATCH 02/14] fix merge --- src/js/tracing/integrations/appStart.ts | 84 +++++++++++++++++++++---- src/js/tracing/reactnativeprofiler.tsx | 1 + 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index c309b85b8c..caf237256c 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -5,13 +5,14 @@ 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 { 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 { getTimeOriginMilliseconds } from '../utils'; +import { getBundleStartTimestampMs, getTimeOriginMilliseconds } from '../utils'; const INTEGRATION_NAME = 'AppStart'; @@ -78,31 +79,33 @@ export const appStartIntegration = (): Integration => { event.contexts.trace.data['SEMANTIC_ATTRIBUTE_SENTRY_OP'] = UI_LOAD_OP; const appStart = await NATIVE.fetchNativeAppStart(); - if (!appStart) { logger.warn('Failed to retrieve the app start metrics from the native layer.'); return; } - - if (appStart.didFetchAppStart) { + if (appStart.has_fetched) { logger.warn('Measured app start metrics were already reported from the native layer.'); return; } + const appStartTimestampMs = appStart.app_start_timestamp_ms; + if (!appStartTimestampMs) { + logger.warn('App start timestamp could not be loaded from the native layer.'); + return; + } if (!appStartEndTimestampMs) { logger.warn('Javascript failed to record app start end.'); return; } - const appStartDurationMs = appStartEndTimestampMs - appStart.appStartTime; - + const appStartDurationMs = appStartEndTimestampMs - appStartTimestampMs; if (appStartDurationMs >= MAX_APP_START_DURATION_MS) { return; } appStartDataFlushed = true; - const appStartTimestampSeconds = appStart.appStartTime / 1000; + const appStartTimestampSeconds = appStartTimestampMs / 1000; event.start_timestamp = appStartTimestampSeconds; event.spans = event.spans || []; @@ -121,18 +124,22 @@ export const appStartIntegration = (): Integration => { setSpanDurationAsMeasurementOnTransactionEvent(event, 'time_to_full_display', maybeTtfdSpan); } - const op = appStart.isColdStart ? APP_START_COLD_OP : APP_START_WARM_OP; - - children.push({ - description: appStart.isColdStart ? 'Cold App Start' : 'Warm App Start', + const op = appStart.type === 'cold' ? APP_START_COLD_OP : APP_START_WARM_OP; + const appStartSpanJSON: SpanJSON = { + description: appStart.type === 'cold' ? 'Cold App Start' : 'Warm App Start', op, start_timestamp: appStartTimestampSeconds, timestamp: appStartEndTimestampMs / 1000, trace_id: event.contexts.trace.trace_id, span_id: uuid4(), - }); - const measurement = appStart.isColdStart ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT; + }; + const jsExecutionSpanJSON = createJSExecutionBeforeRoot(appStartSpanJSON, -1); + children.push(appStartSpanJSON); + jsExecutionSpanJSON && children.push(jsExecutionSpanJSON); + children.push(...convertNativeSpansToSpanJSON(appStartSpanJSON, appStart.spans)); + + const measurement = appStart.type === 'cold' ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT; event.measurements = event.measurements || {}; event.measurements[measurement] = { value: appStartDurationMs, @@ -162,3 +169,54 @@ function setSpanDurationAsMeasurementOnTransactionEvent(event: TransactionEvent, 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 createJSExecutionBeforeRoot( + parentSpan: SpanJSON, + rootComponentFirstConstructorCallTimestampMs: number | undefined, +): SpanJSON | undefined { + const bundleStartTimestampMs = getBundleStartTimestampMs(); + if (!bundleStartTimestampMs) { + return; + } + + if (!rootComponentFirstConstructorCallTimestampMs) { + logger.warn('Missing the root component first constructor call timestamp.'); + return { + description: 'JS Bundle Execution Start', + start_timestamp: bundleStartTimestampMs / 1000, + timestamp: bundleStartTimestampMs / 1000, + span_id: uuid4(), + op: parentSpan.op, + trace_id: parentSpan.trace_id, + parent_span_id: parentSpan.span_id, + }; + } + + return { + description: 'JS Bundle Execution Before React Root', + start_timestamp: bundleStartTimestampMs / 1000, + timestamp: rootComponentFirstConstructorCallTimestampMs / 1000, + span_id: uuid4(), + op: parentSpan.op, + trace_id: parentSpan.trace_id, + parent_span_id: parentSpan.span_id, + }; +} + +/** + * Adds native spans to the app start span. + */ +function convertNativeSpansToSpanJSON(parentSpan: SpanJSON, nativeSpans: NativeAppStartResponse['spans']): SpanJSON[] { + return nativeSpans.map(span => ({ + description: span.description, + start_timestamp: span.start_timestamp_ms / 1000, + timestamp: span.end_timestamp_ms / 1000, + span_id: uuid4(), + op: parentSpan.op, + trace_id: parentSpan.trace_id, + parent_span_id: parentSpan.span_id, + })); +} diff --git a/src/js/tracing/reactnativeprofiler.tsx b/src/js/tracing/reactnativeprofiler.tsx index 7ea56d7c77..8f5e820010 100644 --- a/src/js/tracing/reactnativeprofiler.tsx +++ b/src/js/tracing/reactnativeprofiler.tsx @@ -4,6 +4,7 @@ import { timestampInSeconds } from '@sentry/utils'; import { createIntegration } from '../integrations/factory'; import { setAppStartEndTimestamp } from '../tracing/integrations/appStart'; +import type { ReactNativeTracing } from './reactnativetracing'; const ReactNativeProfilerGlobalState = { appStartReported: false, From d4ac89fc1d08027fd02683e4eac82e539b77497d Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 11 Jun 2024 23:37:39 +0200 Subject: [PATCH 03/14] fix spans, app start is now reported to Sentry --- src/js/integrations/default.ts | 4 + src/js/integrations/exports.ts | 1 + src/js/options.ts | 10 ++ src/js/sdk.tsx | 1 + src/js/tracing/integrations/appStart.ts | 123 ++++++++++++++---------- src/js/tracing/reactnativeprofiler.tsx | 11 +-- src/js/tracing/reactnativetracing.ts | 48 +-------- src/js/tracing/utils.ts | 43 ++++++++- 8 files changed, 134 insertions(+), 107 deletions(-) diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index 37abe6ba57..32bcd4140b 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -4,6 +4,7 @@ import type { ReactNativeClientOptions } from '../options'; import { ReactNativeTracing } from '../tracing'; import { isExpoGo, notWeb } from '../utils/environment'; import { + appStartIntegration, breadcrumbsIntegration, browserApiErrorsIntegration, browserGlobalHandlersIntegration, @@ -96,6 +97,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 b229c3cf50..ca99f5f197 100644 --- a/src/js/integrations/exports.ts +++ b/src/js/integrations/exports.ts @@ -12,6 +12,7 @@ export { screenshotIntegration } from './screenshot'; export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; export { spotlightIntegration } from './spotlight'; +export { appStartIntegration } from '../tracing/integrations/appStart'; export { breadcrumbsIntegration, diff --git a/src/js/options.ts b/src/js/options.ts index bf44620cd7..31955ac198 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -187,6 +187,16 @@ export interface BaseReactNativeOptions { * from the function, no screenshot will be attached. */ 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; } export interface ReactNativeTransportOptions extends BrowserTransportOptions { diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index c54322aebe..23818186af 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -33,6 +33,7 @@ const DEFAULT_OPTIONS: ReactNativeOptions = { attachStacktrace: true, enableCaptureFailedRequests: false, enableNdk: true, + enableAppStartTracking: true, }; /** diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index caf237256c..9b2ad5fa88 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -1,5 +1,5 @@ import type { Event, Integration, SpanJSON, TransactionEvent } from '@sentry/types'; -import { logger, uuid4 } from '@sentry/utils'; +import { logger } from '@sentry/utils'; import { APP_START_COLD as APP_START_COLD_MEASUREMENT, @@ -12,7 +12,7 @@ import { APP_START_WARM as APP_START_WARM_OP, UI_LOAD as UI_LOAD_OP, } from '../ops'; -import { getBundleStartTimestampMs, getTimeOriginMilliseconds } from '../utils'; +import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs, getTimeOriginMilliseconds } from '../utils'; const INTEGRATION_NAME = 'AppStart'; @@ -25,13 +25,14 @@ const MAX_APP_START_DURATION_MS = 60000; let useAppStartEndFromSentryRNProfiler = false; let appStartEndTimestampMs: number | undefined = undefined; +let rootComponentCreationTimestampMs: number | undefined = undefined; /** * Records the application start end. */ -export const setAppStartEndTimestamp = (timestamp: number): void => { +export const setAppStartEndTimestampMs = (timestampMs: number): void => { appStartEndTimestampMs && logger.warn('Overwriting already set app start.'); - appStartEndTimestampMs = timestamp; + appStartEndTimestampMs = timestampMs; }; /** @@ -41,6 +42,13 @@ export const useAppStartFromSentryRNPProfiler = (): void => { useAppStartEndFromSentryRNProfiler = true; }; +/** + * Sets the root component first constructor call timestamp. + */ +export function setRootComponentCreationTimestampMs(timestampMs: number): void { + rootComponentCreationTimestampMs = timestampMs; +} + /** * Adds AppStart spans from the native layer to the transaction event. */ @@ -64,47 +72,48 @@ export const appStartIntegration = (): Integration => { } // eslint-disable-next-line @typescript-eslint/no-floating-promises - attachAppStartToTransactionEvent(event as TransactionEvent); + await attachAppStartToTransactionEvent(event as TransactionEvent); return event; }; async function attachAppStartToTransactionEvent(event: TransactionEvent): Promise { if (!event.contexts || !event.contexts.trace) { - logger.warn('Transaction event is missing trace context. Can not attach app start.'); + logger.warn('[AppStart] Transaction event is missing trace context. Can not attach app start.'); return; } - event.contexts.trace.data = event.contexts.trace.data || {}; - event.contexts.trace.data['SEMANTIC_ATTRIBUTE_SENTRY_OP'] = UI_LOAD_OP; - const appStart = await NATIVE.fetchNativeAppStart(); if (!appStart) { - logger.warn('Failed to retrieve the app start metrics from the native layer.'); + logger.warn('[AppStart] Failed to retrieve the app start metrics from the native layer.'); return; } if (appStart.has_fetched) { - logger.warn('Measured app start metrics were already reported from the native layer.'); + 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('App start timestamp could not be loaded from the native layer.'); + logger.warn('[AppStart] App start timestamp could not be loaded from the native layer.'); return; } if (!appStartEndTimestampMs) { - logger.warn('Javascript failed to record app start end.'); + logger.warn('[AppStart] Javascript failed to record app start end.'); return; } const appStartDurationMs = appStartEndTimestampMs - appStartTimestampMs; if (appStartDurationMs >= MAX_APP_START_DURATION_MS) { + 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; + const appStartTimestampSeconds = appStartTimestampMs / 1000; event.start_timestamp = appStartTimestampSeconds; @@ -125,33 +134,41 @@ export const appStartIntegration = (): Integration => { } const op = appStart.type === 'cold' ? APP_START_COLD_OP : APP_START_WARM_OP; - const appStartSpanJSON: SpanJSON = { - description: appStart.type === 'cold' ? 'Cold App Start' : 'Warm App Start', + const appStartSpanJSON: SpanJSON = createSpanJSON({ op, + description: appStart.type === 'cold' ? 'Cold App Start' : 'Warm App Start', start_timestamp: appStartTimestampSeconds, timestamp: appStartEndTimestampMs / 1000, trace_id: event.contexts.trace.trace_id, - span_id: uuid4(), - }; - const jsExecutionSpanJSON = createJSExecutionBeforeRoot(appStartSpanJSON, -1); - - children.push(appStartSpanJSON); - jsExecutionSpanJSON && children.push(jsExecutionSpanJSON); - children.push(...convertNativeSpansToSpanJSON(appStartSpanJSON, appStart.spans)); - - const measurement = appStart.type === 'cold' ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT; - event.measurements = event.measurements || {}; - event.measurements[measurement] = { + parent_span_id: event.contexts.trace.span_id, + origin: 'auto', + }); + const jsExecutionSpanJSON = createJSExecutionBeforeRoot(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, - setupOnce: () => { - // noop - }, setup, processEvent, }; @@ -175,48 +192,48 @@ function setSpanDurationAsMeasurementOnTransactionEvent(event: TransactionEvent, */ function createJSExecutionBeforeRoot( parentSpan: SpanJSON, - rootComponentFirstConstructorCallTimestampMs: number | undefined, + rootComponentCreationTimestampMs: number | undefined, ): SpanJSON | undefined { const bundleStartTimestampMs = getBundleStartTimestampMs(); if (!bundleStartTimestampMs) { return; } - if (!rootComponentFirstConstructorCallTimestampMs) { + if (!rootComponentCreationTimestampMs) { logger.warn('Missing the root component first constructor call timestamp.'); - return { + return createChildSpanJSON(parentSpan, { description: 'JS Bundle Execution Start', start_timestamp: bundleStartTimestampMs / 1000, timestamp: bundleStartTimestampMs / 1000, - span_id: uuid4(), - op: parentSpan.op, - trace_id: parentSpan.trace_id, - parent_span_id: parentSpan.span_id, - }; + }); } - return { + return createChildSpanJSON(parentSpan, { description: 'JS Bundle Execution Before React Root', start_timestamp: bundleStartTimestampMs / 1000, - timestamp: rootComponentFirstConstructorCallTimestampMs / 1000, - span_id: uuid4(), - op: parentSpan.op, - trace_id: parentSpan.trace_id, - parent_span_id: parentSpan.span_id, - }; + timestamp: rootComponentCreationTimestampMs / 1000, + }); } /** * Adds native spans to the app start span. */ function convertNativeSpansToSpanJSON(parentSpan: SpanJSON, nativeSpans: NativeAppStartResponse['spans']): SpanJSON[] { - return nativeSpans.map(span => ({ - description: span.description, - start_timestamp: span.start_timestamp_ms / 1000, - timestamp: span.end_timestamp_ms / 1000, - span_id: uuid4(), - op: parentSpan.op, - trace_id: parentSpan.trace_id, - parent_span_id: parentSpan.span_id, - })); + return nativeSpans.map(span => { + const spanJSON = createChildSpanJSON(parentSpan, { + description: span.description, + start_timestamp: span.start_timestamp_ms / 1000, + timestamp: span.end_timestamp_ms / 1000, + }); + + if (span.description === 'UIKit init') { + // TODO: check based on time + // 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 + spanJSON.timestamp = spanJSON.start_timestamp; + spanJSON.description = 'UIKit init start'; + } + + return spanJSON; + }); } diff --git a/src/js/tracing/reactnativeprofiler.tsx b/src/js/tracing/reactnativeprofiler.tsx index 8f5e820010..0cb0eee483 100644 --- a/src/js/tracing/reactnativeprofiler.tsx +++ b/src/js/tracing/reactnativeprofiler.tsx @@ -1,10 +1,8 @@ -import { spanToJSON } from '@sentry/core'; import { getClient, Profiler } from '@sentry/react'; import { timestampInSeconds } from '@sentry/utils'; import { createIntegration } from '../integrations/factory'; -import { setAppStartEndTimestamp } from '../tracing/integrations/appStart'; -import type { ReactNativeTracing } from './reactnativetracing'; +import { setAppStartEndTimestampMs, setRootComponentCreationTimestampMs } from '../tracing/integrations/appStart'; const ReactNativeProfilerGlobalState = { appStartReported: false, @@ -17,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); } @@ -49,7 +45,6 @@ export class ReactNativeProfiler extends Profiler { client.addIntegration && client.addIntegration(createIntegration(this.name)); - const endTimestamp = this._mountSpan && typeof spanToJSON(this._mountSpan).timestamp - typeof endTimestamp === 'number' && setAppStartEndTimestamp(endTimestamp); + setAppStartEndTimestampMs(timestampInSeconds() * 1000); } } diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 6f281106e1..ff2cb79a55 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -92,14 +92,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. */ @@ -126,7 +118,6 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { finalTimeoutMs: 600000, ignoreEmptyBackNavigationTransactions: true, beforeNavigate: context => context, - enableAppStartTracking: true, enableNativeFramesTracking: true, enableStallTracking: true, enableUserInteractionTracing: false, @@ -159,7 +150,6 @@ export class ReactNativeTracing implements Integration { private _hasSetTracePropagationTargets: boolean; private _currentViewName: string | undefined; private _client: Client | undefined; - private _firstConstructorCallTimestampMs: number | undefined; public constructor(options: Partial = {}) { this._hasSetTracePropagationTargets = !!( @@ -203,7 +193,6 @@ export class ReactNativeTracing implements Integration { // eslint-disable-next-line deprecation/deprecation tracePropagationTargets: thisOptionsTracePropagationTargets, routingInstrumentation, - enableAppStartTracking, enableStallTracking, } = this.options; @@ -213,10 +202,6 @@ export class ReactNativeTracing implements Integration { (this._hasSetTracePropagationTargets && thisOptionsTracePropagationTargets) || DEFAULT_TRACE_PROPAGATION_TARGETS; - if (enableAppStartTracking) { - this._instrumentAppStart(); - } - this._enableNativeFramesTracking(client); if (enableStallTracking) { @@ -232,6 +217,10 @@ export class ReactNativeTracing implements Integration { ); } else { logger.log('[ReactNativeTracing] Not instrumenting route changes as routingInstrumentation has not been set.'); + this._createRouteTransaction({ + name: 'App Start', + op: UI_LOAD, + }); } addDefaultOpForSpanFrom(client); @@ -254,20 +243,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. @@ -386,21 +361,6 @@ export class ReactNativeTracing implements Integration { return event; } - /** - * Instruments the app start measurements on the first route transaction. - * Starts a route transaction if there isn't routing instrumentation. - */ - private _instrumentAppStart(): void { - if (!this.options.enableAppStartTracking || !NATIVE.enableNative || this.options.routingInstrumentation) { - return; - } - - this._createRouteTransaction({ - name: 'App Start', - op: UI_LOAD, - }); - } - /** To be called when the route changes, but BEFORE the components of the new route mount. */ private _onRouteWillChange(): Span | undefined { return this._createRouteTransaction(); 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, + }); +} From 7c5dcaa23a80acd301c6b9236cfe36091bf78bcf Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 12 Jun 2024 11:20:00 +0200 Subject: [PATCH 04/14] fix uikit init and minimal instrumentation edge cases --- src/js/sdk.tsx | 3 - src/js/tracing/integrations/appStart.ts | 96 +++++++++++++++---------- 2 files changed, 60 insertions(+), 39 deletions(-) diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index 23818186af..eaec58c02b 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 { TouchEventBoundary } from './touchevents'; import { ReactNativeProfiler } from './tracing'; -import { useAppStartFromSentryRNPProfiler } from './tracing/integrations/appStart'; import { useEncodePolyfill } from './transports/encodePolyfill'; import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native'; import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer } from './utils/environment'; @@ -107,8 +106,6 @@ export function wrap

>( RootComponent: React.ComponentType

, options?: ReactNativeWrapperOptions ): React.ComponentType

{ - useAppStartFromSentryRNPProfiler(); - const profilerProps = { ...(options?.profilerProps ?? {}), name: RootComponent.displayName ?? 'Root', diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index 9b2ad5fa88..26136c76ea 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import type { Event, Integration, SpanJSON, TransactionEvent } from '@sentry/types'; import { logger } from '@sentry/utils'; @@ -12,7 +13,7 @@ import { APP_START_WARM as APP_START_WARM_OP, UI_LOAD as UI_LOAD_OP, } from '../ops'; -import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs, getTimeOriginMilliseconds } from '../utils'; +import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs } from '../utils'; const INTEGRATION_NAME = 'AppStart'; @@ -21,31 +22,29 @@ const INTEGRATION_NAME = 'AppStart'; * 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 = 60000; +const MAX_APP_START_DURATION_MS = 60_000; -let useAppStartEndFromSentryRNProfiler = false; -let appStartEndTimestampMs: number | undefined = undefined; +let recordedAppStartEndTimestampMs: number | undefined = undefined; let rootComponentCreationTimestampMs: number | undefined = undefined; /** * Records the application start end. */ export const setAppStartEndTimestampMs = (timestampMs: number): void => { - appStartEndTimestampMs && logger.warn('Overwriting already set app start.'); - appStartEndTimestampMs = timestampMs; -}; - -/** - * Sets the App Start integration to use the application start end from the Sentry React Native Profiler. - */ -export const useAppStartFromSentryRNPProfiler = (): void => { - useAppStartEndFromSentryRNProfiler = true; + recordedAppStartEndTimestampMs && logger.warn('Overwriting already set app start.'); + recordedAppStartEndTimestampMs = timestampMs; }; /** * Sets the root component first constructor call timestamp. + * This depends on `Sentry.wrap` being used. */ export function setRootComponentCreationTimestampMs(timestampMs: number): void { + if (recordedAppStartEndTimestampMs) { + logger.error('Root component creation timestamp can not be set after app start end is set.'); + return; + } + rootComponentCreationTimestampMs = timestampMs; } @@ -55,14 +54,9 @@ export function setRootComponentCreationTimestampMs(timestampMs: number): void { export const appStartIntegration = (): Integration => { let appStartDataFlushed = false; - const setup = (): void => { - if (!useAppStartEndFromSentryRNProfiler) { - appStartEndTimestampMs = getTimeOriginMilliseconds(); - } - }; - const processEvent = async (event: Event): Promise => { if (appStartDataFlushed) { + // App start data is only relevant for the first transaction return event; } @@ -71,7 +65,6 @@ export const appStartIntegration = (): Integration => { return event; } - // eslint-disable-next-line @typescript-eslint/no-floating-promises await attachAppStartToTransactionEvent(event as TransactionEvent); return event; @@ -98,13 +91,18 @@ export const appStartIntegration = (): Integration => { 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.'); + logger.warn( + '[AppStart] Javascript failed to record app start end. `setAppStartEndTimestampMs` was not called nor could the bundle start be found.', + ); return; } const appStartDurationMs = appStartEndTimestampMs - appStartTimestampMs; - if (appStartDurationMs >= MAX_APP_START_DURATION_MS) { + 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; } @@ -133,17 +131,25 @@ export const appStartIntegration = (): Integration => { 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: appStartEndTimestampMs / 1000, + timestamp: appStartEndTimestampSeconds, trace_id: event.contexts.trace.trace_id, parent_span_id: event.contexts.trace.span_id, origin: 'auto', }); - const jsExecutionSpanJSON = createJSExecutionBeforeRoot(appStartSpanJSON, rootComponentCreationTimestampMs); + const jsExecutionSpanJSON = createJSExecutionStartSpan(appStartSpanJSON, rootComponentCreationTimestampMs); const appStartSpans = [ appStartSpanJSON, @@ -169,7 +175,6 @@ export const appStartIntegration = (): Integration => { return { name: INTEGRATION_NAME, - setup, processEvent, }; }; @@ -190,7 +195,7 @@ function setSpanDurationAsMeasurementOnTransactionEvent(event: TransactionEvent, /** * Adds JS Execution before React Root. If `Sentry.wrap` is not used, create a span for the start of JS Bundle execution. */ -function createJSExecutionBeforeRoot( +function createJSExecutionStartSpan( parentSpan: SpanJSON, rootComponentCreationTimestampMs: number | undefined, ): SpanJSON | undefined { @@ -220,20 +225,39 @@ function createJSExecutionBeforeRoot( */ function convertNativeSpansToSpanJSON(parentSpan: SpanJSON, nativeSpans: NativeAppStartResponse['spans']): SpanJSON[] { return nativeSpans.map(span => { - const spanJSON = createChildSpanJSON(parentSpan, { + 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, }); + }); +} - if (span.description === 'UIKit init') { - // TODO: check based on time - // 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 - spanJSON.timestamp = spanJSON.start_timestamp; - spanJSON.description = 'UIKit init start'; - } +/** + * 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(); - return spanJSON; - }); + // If UIKit init ends after the bundle start the native SDK was auto initialize + // and so the end timestamp is incorrect + // The timestamps can't equal as after UIKit RN initializes + if (bundleStart && bundleStart < nativeUIKitSpan.end_timestamp_ms) { + return createChildSpanJSON(parentSpan, { + description: 'UIKit init start', + start_timestamp: nativeUIKitSpan.start_timestamp_ms / 1000, + timestamp: nativeUIKitSpan.start_timestamp_ms / 1000, + }); + } else { + return createChildSpanJSON(parentSpan, { + description: 'UIKit init', + start_timestamp: nativeUIKitSpan.start_timestamp_ms / 1000, + timestamp: nativeUIKitSpan.end_timestamp_ms / 1000, + }); + } } From 0b424725e8453a646513982890ab2a3c66b92c61 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 12 Jun 2024 12:06:58 +0200 Subject: [PATCH 05/14] update js docs --- src/js/tracing/integrations/appStart.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index 26136c76ea..f4504f66ac 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -29,6 +29,7 @@ let rootComponentCreationTimestampMs: number | undefined = undefined; /** * Records the application start end. + * Used automatically by `Sentry.wrap`. */ export const setAppStartEndTimestampMs = (timestampMs: number): void => { recordedAppStartEndTimestampMs && logger.warn('Overwriting already set app start.'); @@ -37,7 +38,7 @@ export const setAppStartEndTimestampMs = (timestampMs: number): void => { /** * Sets the root component first constructor call timestamp. - * This depends on `Sentry.wrap` being used. + * Used automatically by `Sentry.wrap`. */ export function setRootComponentCreationTimestampMs(timestampMs: number): void { if (recordedAppStartEndTimestampMs) { From b9e9e9a3bff057c728fa5052a8b3d4ed5640047c Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 5 Aug 2024 09:24:40 +0200 Subject: [PATCH 06/14] Add App Start Integration tests --- src/js/tracing/integrations/appStart.ts | 15 +- test/integrations/appStart.test.ts | 460 ++++++++++++++++++++++++ 2 files changed, 467 insertions(+), 8 deletions(-) create mode 100644 test/integrations/appStart.test.ts diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index 649cb7d935..ef352aa7a8 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -13,6 +13,7 @@ import { APP_START_WARM as APP_START_WARM_OP, UI_LOAD as UI_LOAD_OP, } from '../ops'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes'; import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs } from '../utils'; const INTEGRATION_NAME = 'AppStart'; @@ -44,11 +45,8 @@ export const setAppStartEndTimestampMs = (timestampMs: number): void => { * Used automatically by `Sentry.wrap`. */ export function setRootComponentCreationTimestampMs(timestampMs: number): void { - if (recordedAppStartEndTimestampMs) { - logger.error('Root component creation timestamp can not be set after app start end is set.'); - return; - } - + recordedAppStartEndTimestampMs && + logger.warn('Setting Root component creation timestamp after app start end is set.'); rootComponentCreationTimestampMs = timestampMs; } @@ -104,8 +102,8 @@ export const appStartIntegration = (): Integration => { return; } - const isAppStartWithinBounds = !!event.start_timestamp && - appStartTimestampMs >= event.start_timestamp - MAX_APP_START_AGE_MS; + 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; @@ -121,7 +119,8 @@ export const appStartIntegration = (): Integration => { appStartDataFlushed = true; event.contexts.trace.data = event.contexts.trace.data || {}; - event.contexts.trace.data['SEMANTIC_ATTRIBUTE_SENTRY_OP'] = UI_LOAD_OP; + 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; diff --git a/test/integrations/appStart.test.ts b/test/integrations/appStart.test.ts new file mode 100644 index 0000000000..576a3c563c --- /dev/null +++ b/test/integrations/appStart.test.ts @@ -0,0 +1,460 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } 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 { + appStartIntegration, + setAppStartEndTimestampMs, + 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(); + }); + + afterEach(() => { + clearReactNativeBundleExecutionStartTimestamp(); + }); + + it('Creates standalone App Start Transaction when no routing instrumentation enabled', () => {}); + + 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( + expectEventWithColdAppStart(actualEvent, { 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( + expectEventWithWarmAppStart(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 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( + expectEventWithWarmAppStart(actualEvent, { 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( + expectEventWithWarmAppStart(actualEvent, { 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 () => { + 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 () => {}); + + it('adds ui kit init full length as a child of the main app start span', async () => {}); + + it('adds ui kit init start mark as a child of the main app start span', async () => {}); + + it('Does not add app start span twice', async () => {}); + + it('Does not add app start span when marked as fetched from the native layer', async () => {}); + + it('Does not add app start if native returns null', async () => {}); + }); +}); + +function processEvent(event: Event): PromiseLike | Event | null { + const integration = appStartIntegration(); + return integration.processEvent(event, {}, new TestClient(getDefaultTestClientOptions())); +} + +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 expectEventWithColdAppStart( + event: 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: '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 expectEventWithWarmAppStart( + event: 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: '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 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, + }); +} From b334931700dd6ff799cca4b286938c946686a4ab Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 5 Aug 2024 12:41:07 +0200 Subject: [PATCH 07/14] Remove app start test from react native tracing --- src/js/tracing/integrations/appStart.ts | 12 +- src/js/tracing/reactnativetracing.ts | 5 - test/integrations/appStart.test.ts | 98 +++- test/tracing/reactnativetracing.test.ts | 659 +----------------------- 4 files changed, 117 insertions(+), 657 deletions(-) diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index ef352aa7a8..67b6bc5362 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -254,18 +254,18 @@ function convertNativeSpansToSpanJSON(parentSpan: SpanJSON, nativeSpans: NativeA 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 initialize - // and so the end timestamp is incorrect - // The timestamps can't equal as after UIKit RN initializes + // 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 start', + description: 'UIKit Init to JS Exec Start', start_timestamp: nativeUIKitSpan.start_timestamp_ms / 1000, - timestamp: nativeUIKitSpan.start_timestamp_ms / 1000, + timestamp: bundleStart / 1000, }); } else { return createChildSpanJSON(parentSpan, { - description: 'UIKit init', + description: 'UIKit Init', start_timestamp: nativeUIKitSpan.start_timestamp_ms / 1000, timestamp: nativeUIKitSpan.end_timestamp_ms / 1000, }); diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 1aff061e7f..0d146190ae 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -23,7 +23,6 @@ import { onlySampleIfChildSpans, onThisSpanEnd, } from './onSpanEndUtils'; -import { UI_LOAD } from './ops'; import { StallTrackingInstrumentation } from './stalltracking'; import type { BeforeNavigate } from './types'; @@ -217,10 +216,6 @@ export class ReactNativeTracing implements Integration { ); } else { logger.log('[ReactNativeTracing] Not instrumenting route changes as routingInstrumentation has not been set.'); - this._createRouteTransaction({ - name: 'App Start', - op: UI_LOAD, - }); } addDefaultOpForSpanFrom(client); diff --git a/test/integrations/appStart.test.ts b/test/integrations/appStart.test.ts index 576a3c563c..440746c3ea 100644 --- a/test/integrations/appStart.test.ts +++ b/test/integrations/appStart.test.ts @@ -56,6 +56,7 @@ jest.mock('@sentry/utils', () => { describe('App Start Integration', () => { beforeEach(() => { mockReactNativeBundleExecutionStartTimestamp(); + jest.clearAllMocks(); }); afterEach(() => { @@ -205,17 +206,106 @@ describe('App Start Integration', () => { ); }); - it('adds native spans as a child of the main app start span', async () => {}); + 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(); - it('adds ui kit init full length as a child of the main app start span', async () => {}); + const actualEvent = await processEvent(getMinimalTransactionEvent()); - it('adds ui kit init start mark as a child of the main app start span', async () => {}); + 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: (timeOriginMilliseconds - 50) / 1000, + }), + ); + }); it('Does not add app start span twice', async () => {}); it('Does not add app start span when marked as fetched from the native layer', async () => {}); - it('Does not add app start if native returns null', async () => {}); + 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); + }); }); }); 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, - }); -} From 4d16787e7693059af11e445a5621345d8b20ae28 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 5 Aug 2024 12:51:42 +0200 Subject: [PATCH 08/14] clean up app start tests --- test/integrations/appStart.test.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/test/integrations/appStart.test.ts b/test/integrations/appStart.test.ts index 440746c3ea..5cdcd456a9 100644 --- a/test/integrations/appStart.test.ts +++ b/test/integrations/appStart.test.ts @@ -63,8 +63,6 @@ describe('App Start Integration', () => { clearReactNativeBundleExecutionStartTimestamp(); }); - it('Creates standalone App Start Transaction when no routing instrumentation enabled', () => {}); - describe('App Start Attached to the First Root Span', () => { it('Does not add App Start Span to Error Event', async () => { const inputEvent: ErrorEvent = { @@ -295,9 +293,32 @@ describe('App Start Integration', () => { ); }); - it('Does not add app start span twice', async () => {}); + 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( + expectEventWithColdAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + ); - it('Does not add app start span when marked as fetched from the native layer', async () => {}); + 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); From 4d8492202634a5773c8f9e0b595dc3bb058d21d4 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 5 Aug 2024 14:49:15 +0200 Subject: [PATCH 09/14] fix test affected by the app start extraction --- test/mocks/client.ts | 5 +++-- test/tracing/reactnativenavigation.test.ts | 2 +- test/tracing/reactnavigation.stalltracking.test.ts | 2 +- test/tracing/reactnavigation.test.ts | 2 +- test/tracing/reactnavigation.ttid.test.tsx | 3 +++ test/tracing/stalltracking.test.ts | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) 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/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/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..4a82093c8a 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); From 0ff2020ddef02a65561916c541aca2e933cf32f8 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 5 Aug 2024 19:45:18 +0200 Subject: [PATCH 10/14] Add standalone app start --- samples/react-native/src/App.tsx | 5 +- src/js/tracing/integrations/appStart.ts | 143 ++++++- src/js/tracing/reactnativeprofiler.tsx | 6 +- src/js/utils/span.ts | 24 +- test/integrations/appStart.test.ts | 448 +++++++++++++++++++-- test/tracing/reactnavigation.ttid.test.tsx | 4 +- 6 files changed, 581 insertions(+), 49 deletions(-) diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 25f9cc4751..556761b4af 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -54,7 +54,7 @@ Sentry.init({ return event; }, beforeSendTransaction(event) { - logWithoutTracing('Transaction beforeSend:', event.event_id); + logWithoutTracing('Transaction beforeSend:', event.event_id, event); return event; }, // This will be called with a boolean `didCallNativeInit` when the native SDK has been contacted. @@ -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/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index 67b6bc5362..db7bd365b2 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -1,23 +1,37 @@ /* eslint-disable complexity */ -import type { Event, Integration, SpanJSON, TransactionEvent } from '@sentry/types'; -import { logger } from '@sentry/utils'; +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. @@ -28,37 +42,97 @@ 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`. + * Used automatically by `Sentry.wrap` and `Sentry.ReactNativeProfiler`. */ -export const setAppStartEndTimestampMs = (timestampMs: number): void => { - recordedAppStartEndTimestampMs && logger.warn('Overwriting already set app start.'); - recordedAppStartEndTimestampMs = timestampMs; -}; +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`. + * 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 = (): Integration => { +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 (appStartDataFlushed) { - // App start data is only relevant for the first transaction + if (!isEnabled || standalone) { return event; } @@ -72,7 +146,51 @@ export const appStartIntegration = (): Integration => { 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; @@ -185,7 +303,10 @@ export const appStartIntegration = (): Integration => { return { name: INTEGRATION_NAME, + setup, + afterAllSetup, processEvent, + captureStandaloneAppStart, }; }; diff --git a/src/js/tracing/reactnativeprofiler.tsx b/src/js/tracing/reactnativeprofiler.tsx index 0cb0eee483..7fbb05345e 100644 --- a/src/js/tracing/reactnativeprofiler.tsx +++ b/src/js/tracing/reactnativeprofiler.tsx @@ -2,7 +2,7 @@ import { getClient, Profiler } from '@sentry/react'; import { timestampInSeconds } from '@sentry/utils'; import { createIntegration } from '../integrations/factory'; -import { setAppStartEndTimestampMs, setRootComponentCreationTimestampMs } from '../tracing/integrations/appStart'; +import { captureAppStart, setRootComponentCreationTimestampMs } from '../tracing/integrations/appStart'; const ReactNativeProfilerGlobalState = { appStartReported: false, @@ -44,7 +44,7 @@ export class ReactNativeProfiler extends Profiler { } client.addIntegration && client.addIntegration(createIntegration(this.name)); - - setAppStartEndTimestampMs(timestampInSeconds() * 1000); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + captureAppStart(); } } 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/integrations/appStart.test.ts b/test/integrations/appStart.test.ts index 5cdcd456a9..e9bb148ba5 100644 --- a/test/integrations/appStart.test.ts +++ b/test/integrations/appStart.test.ts @@ -1,4 +1,11 @@ -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +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'; @@ -13,8 +20,9 @@ import { UI_LOAD, } from '../../src/js/tracing'; import { + _clearRootComponentCreationTimestampMs, + _setAppStartEndTimestampMs, appStartIntegration, - setAppStartEndTimestampMs, setRootComponentCreationTimestampMs, } from '../../src/js/tracing/integrations/appStart'; import { getTimeOriginMilliseconds } from '../../src/js/tracing/utils'; @@ -63,6 +71,275 @@ describe('App Start Integration', () => { 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 = { @@ -80,7 +357,7 @@ describe('App Start Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); expect(actualEvent).toEqual( - expectEventWithColdAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + expectEventWithAttachedColdAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), ); }); @@ -89,7 +366,7 @@ describe('App Start Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); expect(actualEvent).toEqual( - expectEventWithWarmAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + expectEventWithAttachedWarmAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), ); }); @@ -107,7 +384,7 @@ describe('App Start Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); expect(actualEvent).toEqual( - expectEventWithWarmAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + expectEventWithAttachedWarmAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), ); }); @@ -127,7 +404,7 @@ describe('App Start Integration', () => { getMinimalTransactionEvent({ startTimestampSeconds: timeOriginMilliseconds }), ); expect(actualEvent).toEqual( - expectEventWithWarmAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + expectEventWithAttachedWarmAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), ); }); @@ -146,6 +423,7 @@ describe('App Start Integration', () => { }); it('Adds bundle execution span', async () => { + _clearRootComponentCreationTimestampMs(); mockReactNativeBundleExecutionStartTimestamp(); const [timeOriginMilliseconds] = mockAppStart({ cold: true }); @@ -288,7 +566,7 @@ describe('App Start Integration', () => { expect.objectContaining({ description: 'UIKit Init to JS Exec Start', start_timestamp: (timeOriginMilliseconds - 100) / 1000, - timestamp: (timeOriginMilliseconds - 50) / 1000, + timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), }), ); }); @@ -301,7 +579,7 @@ describe('App Start Integration', () => { const actualEvent = await integration.processEvent(getMinimalTransactionEvent(), {}, client); expect(actualEvent).toEqual( - expectEventWithColdAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + expectEventWithAttachedColdAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), ); const secondEvent = await integration.processEvent(getMinimalTransactionEvent(), {}, client); @@ -335,6 +613,26 @@ function processEvent(event: Event): PromiseLike | Event | null { 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, }: { @@ -363,16 +661,13 @@ function getMinimalTransactionEvent({ }; } -function expectEventWithColdAppStart( - event: Event, - { - timeOriginMilliseconds, - appStartTimeMilliseconds, - }: { - timeOriginMilliseconds: number; - appStartTimeMilliseconds: number; - }, -) { +function expectEventWithAttachedColdAppStart({ + timeOriginMilliseconds, + appStartTimeMilliseconds, +}: { + timeOriginMilliseconds: number; + appStartTimeMilliseconds: number; +}) { return expect.objectContaining({ type: 'transaction', start_timestamp: appStartTimeMilliseconds / 1000, @@ -418,16 +713,13 @@ function expectEventWithColdAppStart( }); } -function expectEventWithWarmAppStart( - event: Event, - { - timeOriginMilliseconds, - appStartTimeMilliseconds, - }: { - timeOriginMilliseconds: number; - appStartTimeMilliseconds: number; - }, -) { +function expectEventWithAttachedWarmAppStart({ + timeOriginMilliseconds, + appStartTimeMilliseconds, +}: { + timeOriginMilliseconds: number; + appStartTimeMilliseconds: number; +}) { return expect.objectContaining({ type: 'transaction', start_timestamp: appStartTimeMilliseconds / 1000, @@ -473,6 +765,100 @@ function expectEventWithWarmAppStart( }); } +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, @@ -502,7 +888,7 @@ function mockAppStart({ : [], }; - setAppStartEndTimestampMs(timeOriginMilliseconds); + _setAppStartEndTimestampMs(timeOriginMilliseconds); mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); @@ -519,7 +905,7 @@ function mockTooLongAppStart() { spans: [], }; - setAppStartEndTimestampMs(timeOriginMilliseconds); + _setAppStartEndTimestampMs(timeOriginMilliseconds); mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); @@ -537,7 +923,7 @@ function mockTooOldAppStart() { }; // App start finish timestamp - setAppStartEndTimestampMs(timeOriginMilliseconds); + _setAppStartEndTimestampMs(timeOriginMilliseconds); mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds - 64000); mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); // Transaction start timestamp diff --git a/test/tracing/reactnavigation.ttid.test.tsx b/test/tracing/reactnavigation.ttid.test.tsx index 4a82093c8a..f82996c6e0 100644 --- a/test/tracing/reactnavigation.ttid.test.tsx +++ b/test/tracing/reactnavigation.ttid.test.tsx @@ -14,7 +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 { _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'; @@ -42,7 +42,7 @@ describe('React Navigation - TTID', () => { type: 'cold', spans: [], }); - setAppStartEndTimestampMs(mockedAppStartTimeSeconds * 1000); + _setAppStartEndTimestampMs(mockedAppStartTimeSeconds * 1000); mockedEventEmitter = mockedSentryEventEmitter.createMockedSentryEventEmitter(); (createSentryEventEmitter as jest.Mock).mockReturnValue(mockedEventEmitter); From b1eab513be046421701d69b936f7ebd31eaaf185 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 5 Aug 2024 19:48:33 +0200 Subject: [PATCH 11/14] fix --- samples/react-native/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 556761b4af..ece3bb0ae9 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -54,7 +54,7 @@ Sentry.init({ return event; }, beforeSendTransaction(event) { - logWithoutTracing('Transaction beforeSend:', event.event_id, event); + logWithoutTracing('Transaction beforeSend:', event.event_id); return event; }, // This will be called with a boolean `didCallNativeInit` when the native SDK has been contacted. From 5eaaad24340b90b755f4c7ecd424e1670d85a5cf Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 09:47:46 +0200 Subject: [PATCH 12/14] Add integration handling test --- test/sdk.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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, From e199244803c20677e8969f2164f6293ad5134de2 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 11:07:38 +0200 Subject: [PATCH 13/14] add changelog --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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)) From 699fda7b6e736a03d4e977a4555f11ae8baad38d Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 12:51:17 +0200 Subject: [PATCH 14/14] move the app start test to tracing --- .../integrations/appStart.test.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) rename test/{ => tracing}/integrations/appStart.test.ts (97%) diff --git a/test/integrations/appStart.test.ts b/test/tracing/integrations/appStart.test.ts similarity index 97% rename from test/integrations/appStart.test.ts rename to test/tracing/integrations/appStart.test.ts index e9bb148ba5..09e8c53294 100644 --- a/test/integrations/appStart.test.ts +++ b/test/tracing/integrations/appStart.test.ts @@ -12,26 +12,26 @@ 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'; +} 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'; +} 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', () => { +} 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(), @@ -43,8 +43,8 @@ jest.mock('../../src/js/wrapper', () => { }; }); -jest.mock('../../src/js/tracing/utils', () => { - const originalUtils = jest.requireActual('../../src/js/tracing/utils'); +jest.mock('../../../src/js/tracing/utils', () => { + const originalUtils = jest.requireActual('../../../src/js/tracing/utils'); return { ...originalUtils,