From c3dcfd3cc59413bf47ca96657f3543eda9fe41fb Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 3 Jun 2024 18:39:03 +0200 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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 6d1cd701b5e2d953da33d207dc3a25ebe03cd94f Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 5 Aug 2024 20:23:54 +0200 Subject: [PATCH 12/37] ref(tracing): Extract NativeFrames as standalone integration --- src/js/integrations/default.ts | 10 +- src/js/integrations/exports.ts | 1 + src/js/options.ts | 10 +- src/js/sdk.tsx | 1 + .../nativeFrames.ts} | 134 +++++++++++------- src/js/tracing/reactnativetracing.ts | 42 +----- 6 files changed, 101 insertions(+), 97 deletions(-) rename src/js/tracing/{nativeframes.ts => integrations/nativeFrames.ts} (73%) diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index edf7011479..03479ea2df 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -24,6 +24,7 @@ import { inboundFiltersIntegration, mobileReplayIntegration, modulesLoaderIntegration, + nativeFramesIntegration, nativeLinkedErrorsIntegration, nativeReleaseIntegration, reactNativeErrorHandlersIntegration, @@ -98,12 +99,15 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ options.enableTracing || typeof options.tracesSampleRate === 'number' || typeof options.tracesSampler === 'function'; - if (hasTracingEnabled && options.enableAutoPerformanceTracing) { - integrations.push(new ReactNativeTracing()); - } if (hasTracingEnabled && options.enableAppStartTracking) { integrations.push(appStartIntegration()); } + if (hasTracingEnabled && options.enableNativeFramesTracking) { + integrations.push(nativeFramesIntegration()); + } + if (hasTracingEnabled && options.enableAutoPerformanceTracing) { + integrations.push(new ReactNativeTracing()); + } if (options.enableCaptureFailedRequests) { integrations.push(httpClientIntegration()); } diff --git a/src/js/integrations/exports.ts b/src/js/integrations/exports.ts index 2a34136673..3335cb7128 100644 --- a/src/js/integrations/exports.ts +++ b/src/js/integrations/exports.ts @@ -14,6 +14,7 @@ export { expoContextIntegration } from './expocontext'; export { spotlightIntegration } from './spotlight'; export { mobileReplayIntegration } from '../replay/mobilereplay'; export { appStartIntegration } from '../tracing/integrations/appStart'; +export { nativeFramesIntegration } from '../tracing/integrations/nativeFrames'; export { breadcrumbsIntegration, diff --git a/src/js/options.ts b/src/js/options.ts index 1d4451a952..81bf8bd308 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -194,10 +194,18 @@ export interface BaseReactNativeOptions { * * Requires performance monitoring to be enabled. * - * Default: true + * @default true */ enableAppStartTracking?: boolean; + /** + * Track the slow and frozen frames in the application. Enabling this options will add + * slow and frozen frames measurements to all created root spans (transactions). + * + * @default true + */ + enableNativeFramesTracking?: boolean; + /** * Options which are in beta, or otherwise not guaranteed to be stable. */ diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index 246f635eed..72e3ed3261 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -34,6 +34,7 @@ const DEFAULT_OPTIONS: ReactNativeOptions = { enableCaptureFailedRequests: false, enableNdk: true, enableAppStartTracking: true, + enableNativeFramesTracking: true, }; /** diff --git a/src/js/tracing/nativeframes.ts b/src/js/tracing/integrations/nativeFrames.ts similarity index 73% rename from src/js/tracing/nativeframes.ts rename to src/js/tracing/integrations/nativeFrames.ts index acf43f5e93..2c27a25b6a 100644 --- a/src/js/tracing/nativeframes.ts +++ b/src/js/tracing/integrations/nativeFrames.ts @@ -2,9 +2,10 @@ import { spanToJSON } from '@sentry/core'; import type { Client, Event, Integration, Measurements, MeasurementUnit, Span } from '@sentry/types'; import { logger, timestampInSeconds } from '@sentry/utils'; -import type { NativeFramesResponse } from '../NativeRNSentry'; -import { isRootSpan } from '../utils/span'; -import { NATIVE } from '../wrapper'; +import type { NativeFramesResponse } from '../../NativeRNSentry'; +import type { ReactNativeClientOptions } from '../../options'; +import { isRootSpan } from '../../utils/span'; +import { NATIVE } from '../../wrapper'; /** * Timeout from the final native frames fetch to processing the associated transaction. @@ -31,45 +32,68 @@ const _finishFrames: Map { + const name: string = 'NativeFramesInstrumentation'; /** The native frames at the finish time of the most recent span. */ - private _lastSpanFinishFrames?: { - timestamp: number; - nativeFrames: NativeFramesResponse; - }; - private _spanToNativeFramesAtStartMap: Map = new Map(); - - public constructor() { - logger.log('[ReactNativeTracing] Native frames instrumentation initialized.'); - } + let _lastSpanFinishFrames: + | { + timestamp: number; + nativeFrames: NativeFramesResponse; + } + | undefined = undefined; + const _spanToNativeFramesAtStartMap: Map = new Map(); /** * Hooks into the client start and end span events. */ - public setup(client: Client): void { - client.on('spanStart', this._onSpanStart); - client.on('spanEnd', this._onSpanFinish); - } + const setup = (client: Client): void => { + const { enableNativeFramesTracking } = client.getOptions() as ReactNativeClientOptions; + + if (enableNativeFramesTracking && !NATIVE.enableNative) { + // Do not enable native frames tracking if native is not available. + logger.warn( + '[ReactNativeTracing] NativeFramesTracking is not available on the Web, Expo Go and other platforms without native modules.', + ); + return; + } + + if (!enableNativeFramesTracking && NATIVE.enableNative) { + // Disable native frames tracking when native available and option is false. + NATIVE.disableNativeFramesTracking(); + return; + } + + if (!enableNativeFramesTracking) { + return; + } + + NATIVE.enableNativeFramesTracking(); + + client.on('spanStart', _onSpanStart); + client.on('spanEnd', _onSpanFinish); + logger.log('[ReactNativeTracing] Native frames instrumentation initialized.'); + }; /** * Adds frames measurements to an event. Called from a valid event processor. * Awaits for finish frames if needed. */ - public processEvent(event: Event): Promise { - return this._processEvent(event); - } + const processEvent = (event: Event): Promise => { + return _processEvent(event); + }; /** * Fetches the native frames in background if the given span is a root span. * * @param {Span} rootSpan - The span that has started. */ - private _onSpanStart = (rootSpan: Span): void => { + const _onSpanStart = (rootSpan: Span): void => { if (!isRootSpan(rootSpan)) { return; } @@ -87,7 +111,7 @@ export class NativeFramesInstrumentation implements Integration { return; } - this._spanToNativeFramesAtStartMap.set(rootSpan.spanContext().traceId, frames); + _spanToNativeFramesAtStartMap.set(rootSpan.spanContext().traceId, frames); }) .then(undefined, error => { logger.error( @@ -101,9 +125,9 @@ export class NativeFramesInstrumentation implements Integration { * Called on a span finish to fetch native frames to support transactions with trimEnd. * Only to be called when a span does not have an end timestamp. */ - private _onSpanFinish = (span: Span): void => { + const _onSpanFinish = (span: Span): void => { if (isRootSpan(span)) { - return this._onTransactionFinish(span); + return _onTransactionFinish(span); } const timestamp = timestampInSeconds(); @@ -114,7 +138,7 @@ export class NativeFramesInstrumentation implements Integration { return; } - this._lastSpanFinishFrames = { + _lastSpanFinishFrames = { timestamp, nativeFrames: frames, }; @@ -127,26 +151,26 @@ export class NativeFramesInstrumentation implements Integration { /** * To be called when a transaction is finished */ - private _onTransactionFinish(span: Span): void { - this._fetchFramesForTransaction(span).then(undefined, (reason: unknown) => { + const _onTransactionFinish = (span: Span): void => { + _fetchFramesForTransaction(span).then(undefined, (reason: unknown) => { logger.error( `[NativeFrames] Error while fetching frames for root span start (${span.spanContext().spanId})`, reason, ); }); - } + }; /** * Returns the computed frames measurements and awaits for them if they are not ready yet. */ - private async _getFramesMeasurements( + const _getFramesMeasurements = ( traceId: string, finalEndTimestamp: number, startFrames: NativeFramesResponse, - ): Promise { + ): Promise => { if (_finishFrames.has(traceId)) { logger.debug(`[NativeFrames] Native end frames already fetched for trace id (${traceId}).`); - return this._prepareMeasurements(traceId, finalEndTimestamp, startFrames); + return Promise.resolve(_prepareMeasurements(traceId, finalEndTimestamp, startFrames)); } return new Promise(resolve => { @@ -159,22 +183,22 @@ export class NativeFramesInstrumentation implements Integration { _framesListeners.set(traceId, () => { logger.debug(`[NativeFrames] Native end frames listener called for trace id (${traceId}).`); - resolve(this._prepareMeasurements(traceId, finalEndTimestamp, startFrames)); + resolve(_prepareMeasurements(traceId, finalEndTimestamp, startFrames)); clearTimeout(timeout); _framesListeners.delete(traceId); }); }); - } + }; /** * Returns the computed frames measurements given ready data */ - private _prepareMeasurements( + const _prepareMeasurements = ( traceId: string, finalEndTimestamp: number, // The actual transaction finish time. startFrames: NativeFramesResponse, - ): FramesMeasurements | null { + ): FramesMeasurements | null => { let finalFinishFrames: NativeFramesResponse | undefined; const finish = _finishFrames.get(traceId); @@ -187,13 +211,13 @@ export class NativeFramesInstrumentation implements Integration { logger.debug(`[NativeFrames] Using frames from root span end (traceId, ${traceId}).`); finalFinishFrames = finish.nativeFrames; } else if ( - this._lastSpanFinishFrames && - Math.abs(this._lastSpanFinishFrames.timestamp - finalEndTimestamp) < MARGIN_OF_ERROR_SECONDS + _lastSpanFinishFrames && + Math.abs(_lastSpanFinishFrames.timestamp - finalEndTimestamp) < MARGIN_OF_ERROR_SECONDS ) { // Fallback to the last span finish if it is within the margin of error of the actual finish timestamp. // This should be the case for trimEnd. logger.debug(`[NativeFrames] Using native frames from last span end (traceId, ${traceId}).`); - finalFinishFrames = this._lastSpanFinishFrames.nativeFrames; + finalFinishFrames = _lastSpanFinishFrames.nativeFrames; } else { logger.warn( `[NativeFrames] Frames were collected within larger than margin of error delay for traceId (${traceId}). Dropping the inaccurate values.`, @@ -228,18 +252,18 @@ export class NativeFramesInstrumentation implements Integration { } return measurements; - } + }; /** * Fetch finish frames for a transaction at the current time. Calls any awaiting listeners. */ - private async _fetchFramesForTransaction(span: Span): Promise { + const _fetchFramesForTransaction = async (span: Span): Promise => { const traceId = spanToJSON(span).trace_id; if (!traceId) { return; } - const startFrames = this._spanToNativeFramesAtStartMap.get(span.spanContext().traceId); + const startFrames = _spanToNativeFramesAtStartMap.get(span.spanContext().traceId); // This timestamp marks when the finish frames were retrieved. It should be pretty close to the transaction finish. const timestamp = timestampInSeconds(); @@ -255,13 +279,13 @@ export class NativeFramesInstrumentation implements Integration { _framesListeners.get(traceId)?.(); - setTimeout(() => this._cancelEndFrames(span), FINAL_FRAMES_TIMEOUT_MS); - } + setTimeout(() => _cancelEndFrames(span), FINAL_FRAMES_TIMEOUT_MS); + }; /** * On a finish frames failure, we cancel the await. */ - private _cancelEndFrames(span: Span): void { + const _cancelEndFrames = (span: Span): void => { const spanJSON = spanToJSON(span); const traceId = spanJSON.trace_id; if (!traceId) { @@ -275,13 +299,13 @@ export class NativeFramesInstrumentation implements Integration { `[NativeFrames] Native frames timed out for ${spanJSON.op} transaction ${spanJSON.description}. Not adding native frames measurements.`, ); } - } + }; /** * Adds frames measurements to an event. Called from a valid event processor. * Awaits for finish frames if needed. */ - private async _processEvent(event: Event): Promise { + const _processEvent = async (event: Event): Promise => { if ( event.type !== 'transaction' || !event.transaction || @@ -295,8 +319,8 @@ export class NativeFramesInstrumentation implements Integration { const traceOp = event.contexts.trace.op; const traceId = event.contexts.trace.trace_id; - const startFrames = this._spanToNativeFramesAtStartMap.get(traceId); - this._spanToNativeFramesAtStartMap.delete(traceId); + const startFrames = _spanToNativeFramesAtStartMap.get(traceId); + _spanToNativeFramesAtStartMap.delete(traceId); if (!startFrames) { logger.warn( `[NativeFrames] Start frames of transaction ${event.transaction} (eventId, ${event.event_id}) are missing, but it already ended.`, @@ -304,7 +328,7 @@ export class NativeFramesInstrumentation implements Integration { return event; } - const measurements = await this._getFramesMeasurements(traceId, event.timestamp, startFrames); + const measurements = await _getFramesMeasurements(traceId, event.timestamp, startFrames); if (!measurements) { logger.log( @@ -329,5 +353,11 @@ export class NativeFramesInstrumentation implements Integration { _finishFrames.delete(traceId); return event; - } -} + }; + + return { + name, + setup, + processEvent, + }; +}; diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 0d146190ae..8c2e5ef1ac 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -14,8 +14,6 @@ import type { Client, Event, Integration, PropagationContext, Scope, Span, Start import { logger, uuid4 } from '@sentry/utils'; import type { RoutingInstrumentationInstance } from '../tracing/routingInstrumentation'; -import { NATIVE } from '../wrapper'; -import { NativeFramesInstrumentation } from './nativeframes'; import { adjustTransactionDuration, cancelInBackground, @@ -91,11 +89,6 @@ export interface ReactNativeTracingOptions extends RequestInstrumentationOptions */ beforeNavigate: BeforeNavigate; - /** - * Track slow/frozen frames from the native layer and adds them as measurements to all transactions. - */ - enableNativeFramesTracking: boolean; - /** * Track when and how long the JS event loop stalls for. Adds stalls as measurements to all transactions. */ @@ -117,7 +110,6 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { finalTimeoutMs: 600000, ignoreEmptyBackNavigationTransactions: true, beforeNavigate: context => context, - enableNativeFramesTracking: true, enableStallTracking: true, enableUserInteractionTracing: false, }; @@ -139,7 +131,6 @@ export class ReactNativeTracing implements Integration { /** ReactNativeTracing options */ public options: ReactNativeTracingOptions; - public nativeFramesInstrumentation?: NativeFramesInstrumentation; public stallTrackingInstrumentation?: StallTrackingInstrumentation; public useAppStartWithProfiler: boolean = false; @@ -201,8 +192,6 @@ export class ReactNativeTracing implements Integration { (this._hasSetTracePropagationTargets && thisOptionsTracePropagationTargets) || DEFAULT_TRACE_PROPAGATION_TARGETS; - this._enableNativeFramesTracking(client); - if (enableStallTracking) { this.stallTrackingInstrumentation = new StallTrackingInstrumentation(); this.stallTrackingInstrumentation.setup(client); @@ -233,9 +222,7 @@ export class ReactNativeTracing implements Integration { */ public processEvent(event: Event): Promise | Event { const eventWithView = this._getCurrentViewEventProcessor(event); - return this.nativeFramesInstrumentation - ? this.nativeFramesInstrumentation.processEvent(eventWithView) - : eventWithView; + return eventWithView; } /** @@ -318,33 +305,6 @@ export class ReactNativeTracing implements Integration { return this._inflightInteractionTransaction; } - /** - * Enables or disables native frames tracking based on the `enableNativeFramesTracking` option. - */ - private _enableNativeFramesTracking(client: Client): void { - if (this.options.enableNativeFramesTracking && !NATIVE.enableNative) { - // Do not enable native frames tracking if native is not available. - logger.warn( - '[ReactNativeTracing] NativeFramesTracking is not available on the Web, Expo Go and other platforms without native modules.', - ); - return; - } - - if (!this.options.enableNativeFramesTracking && NATIVE.enableNative) { - // Disable native frames tracking when native available and option is false. - NATIVE.disableNativeFramesTracking(); - return; - } - - if (!this.options.enableNativeFramesTracking) { - return; - } - - NATIVE.enableNativeFramesTracking(); - this.nativeFramesInstrumentation = new NativeFramesInstrumentation(); - this.nativeFramesInstrumentation.setup(client); - } - /** * Sets the current view name into the app context. * @param event Le event. From 5eaaad24340b90b755f4c7ecd424e1670d85a5cf Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 09:47:46 +0200 Subject: [PATCH 13/37] 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 91c1eb885dd2e99505f26a2ebf9698db327bfee6 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 10:35:48 +0200 Subject: [PATCH 14/37] clean up integrations tests --- src/js/tracing/integrations/nativeFrames.ts | 2 +- test/sdk.test.ts | 170 ++++++++------------ 2 files changed, 67 insertions(+), 105 deletions(-) diff --git a/src/js/tracing/integrations/nativeFrames.ts b/src/js/tracing/integrations/nativeFrames.ts index 2c27a25b6a..770ab115b4 100644 --- a/src/js/tracing/integrations/nativeFrames.ts +++ b/src/js/tracing/integrations/nativeFrames.ts @@ -38,7 +38,7 @@ const MARGIN_OF_ERROR_SECONDS = 0.05; * Instrumentation to add native slow/frozen frames measurements onto transactions. */ export const nativeFramesIntegration = (): Integration => { - const name: string = 'NativeFramesInstrumentation'; + const name: string = 'NativeFrames'; /** The native frames at the finish time of the most recent span. */ let _lastSpanFinishFrames: diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 672dc1959a..01c5c7ed5c 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -315,10 +315,7 @@ describe('Tests the SDK functionality', () => { it('no http client integration by default', () => { init({}); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'HttpClient' })])); + expectNotIntegration('HttpClient'); }); it('adds http client integration', () => { @@ -326,10 +323,7 @@ describe('Tests the SDK functionality', () => { enableCaptureFailedRequests: true, }); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'HttpClient' })])); + expectIntegration('HttpClient'); }); it('user defined http client integration overwrites default', () => { @@ -361,10 +355,7 @@ describe('Tests the SDK functionality', () => { it('no screenshot integration by default', () => { init({}); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'Screenshot' })])); + expectNotIntegration('Screenshot'); }); it('adds screenshot integration', () => { @@ -372,21 +363,13 @@ describe('Tests the SDK functionality', () => { attachScreenshot: true, }); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Screenshot' })])); + expectIntegration('Screenshot'); }); it('no view hierarchy integration by default', () => { init({}); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual( - expect.not.arrayContaining([expect.objectContaining({ name: 'ViewHierarchy' })]), - ); + expectNotIntegration('ViewHierarchy'); }); it('adds view hierarchy integration', () => { @@ -394,20 +377,13 @@ describe('Tests the SDK functionality', () => { attachViewHierarchy: true, }); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'ViewHierarchy' })])); + expectIntegration('ViewHierarchy'); }); it('no profiling integration by default', () => { init({}); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - expect(actualIntegrations).toEqual( - expect.not.arrayContaining([expect.objectContaining({ name: 'HermesProfiling' })]), - ); + expectNotIntegration('HermesProfiling'); }); it('adds profiling integration', () => { @@ -417,19 +393,13 @@ describe('Tests the SDK functionality', () => { }, }); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - expect(actualIntegrations).toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'HermesProfiling' })]), - ); + expectIntegration('HermesProfiling'); }); it('no spotlight integration by default', () => { init({}); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'Spotlight' })])); + expectNotIntegration('Spotlight'); }); it('adds spotlight integration', () => { @@ -437,17 +407,13 @@ describe('Tests the SDK functionality', () => { enableSpotlight: true, }); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Spotlight' })])); + expectIntegration('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' })])); + expectNotIntegration('AppStart'); }); it('when tracing enabled app start integration added by default', () => { @@ -455,9 +421,7 @@ describe('Tests the SDK functionality', () => { tracesSampleRate: 0.5, }); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'AppStart' })])); + expectIntegration('AppStart'); }); it('when tracing enabled and app start disabled the integration is not added', () => { @@ -466,9 +430,30 @@ describe('Tests the SDK functionality', () => { enableAppStartTracking: false, }); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'AppStart' })])); + expectNotIntegration('AppStart'); + }); + + it('no native frames integration by default', () => { + init({}); + + expectNotIntegration('NativeFrames'); + }); + + it('when tracing enabled native frames integration added by default', () => { + init({ + tracesSampleRate: 0.5, + }); + + expectIntegration('NativeFrames'); + }); + + it('when tracing enabled and native frames disabled the integration is not added', () => { + init({ + tracesSampleRate: 0.5, + enableNativeFramesTracking: false, + }); + + expectNotIntegration('NativeFrames'); }); it('no default integrations', () => { @@ -561,50 +546,29 @@ describe('Tests the SDK functionality', () => { it('adds react default integrations', () => { init({}); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'InboundFilters' }), - expect.objectContaining({ name: 'FunctionToString' }), - expect.objectContaining({ name: 'Breadcrumbs' }), - expect.objectContaining({ name: 'Dedupe' }), - expect.objectContaining({ name: 'HttpContext' }), - ]), - ); + expectIntegration('InboundFilters'); + expectIntegration('FunctionToString'); + expectIntegration('Breadcrumbs'); + expectIntegration('Dedupe'); + expectIntegration('HttpContext'); }); it('adds all platform default integrations', () => { init({}); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'Release' }), - expect.objectContaining({ name: 'EventOrigin' }), - expect.objectContaining({ name: 'SdkInfo' }), - expect.objectContaining({ name: 'ReactNativeInfo' }), - ]), - ); + expectIntegration('Release'); + expectIntegration('EventOrigin'); + expectIntegration('SdkInfo'); + expectIntegration('ReactNativeInfo'); }); it('adds web platform specific default integrations', () => { (notWeb as jest.Mock).mockImplementation(() => false); init({}); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'BrowserApiErrors' }), - expect.objectContaining({ name: 'GlobalHandlers' }), - expect.objectContaining({ name: 'LinkedErrors' }), - ]), - ); + expectIntegration('BrowserApiErrors'); + expectIntegration('GlobalHandlers'); + expectIntegration('LinkedErrors'); }); it('does not add native integrations if native disabled', () => { @@ -617,22 +581,11 @@ describe('Tests the SDK functionality', () => { }, }); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual( - expect.not.arrayContaining([expect.objectContaining({ name: 'DeviceContext' })]), - ); - expect(actualIntegrations).toEqual( - expect.not.arrayContaining([expect.objectContaining({ name: 'ModulesLoader' })]), - ); - expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'Screenshot' })])); - expect(actualIntegrations).toEqual( - expect.not.arrayContaining([expect.objectContaining({ name: 'ViewHierarchy' })]), - ); - expect(actualIntegrations).toEqual( - expect.not.arrayContaining([expect.objectContaining({ name: 'HermesProfiling' })]), - ); + expectNotIntegration('DeviceContext'); + expectNotIntegration('ModulesLoader'); + expectNotIntegration('Screenshot'); + expectNotIntegration('ViewHierarchy'); + expectNotIntegration('HermesProfiling'); }); }); @@ -640,13 +593,22 @@ describe('Tests the SDK functionality', () => { (isExpoGo as jest.Mock).mockImplementation(() => true); init({}); - const actualOptions = usedOptions(); - const actualIntegrations = actualOptions?.integrations; - - expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'ExpoContext' })])); + expectIntegration('ExpoContext'); }); }); +function expectIntegration(name: string): void { + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; + expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name })])); +} + +function expectNotIntegration(name: string): void { + const actualOptions = usedOptions(); + const actualIntegrations = actualOptions?.integrations; + expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name })])); +} + function createMockedIntegration({ name }: { name?: string } = {}): Integration { return { name: name ?? 'MockedIntegration', From db70c0932f063e98a68f088f57eadadb6c240f41 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 11:07:29 +0200 Subject: [PATCH 15/37] move native frames tests --- test/tracing/{ => integrations}/nativeframes.test.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/tracing/{ => integrations}/nativeframes.test.ts (100%) diff --git a/test/tracing/nativeframes.test.ts b/test/tracing/integrations/nativeframes.test.ts similarity index 100% rename from test/tracing/nativeframes.test.ts rename to test/tracing/integrations/nativeframes.test.ts From e199244803c20677e8969f2164f6293ad5134de2 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 11:07:38 +0200 Subject: [PATCH 16/37] 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 adb53fce2d7832f65f146b196cfff4c59c3386ae Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 11:40:45 +0200 Subject: [PATCH 17/37] fix --- test/tracing/integrations/nativeframes.test.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/test/tracing/integrations/nativeframes.test.ts b/test/tracing/integrations/nativeframes.test.ts index 1ca2f70f8f..1a0e3bd2af 100644 --- a/test/tracing/integrations/nativeframes.test.ts +++ b/test/tracing/integrations/nativeframes.test.ts @@ -1,12 +1,12 @@ import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, startSpan } from '@sentry/core'; import type { Event, Measurements } from '@sentry/types'; -import { ReactNativeTracing } from '../../src/js'; -import { NATIVE } from '../../src/js/wrapper'; -import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; -import { mockFunction } from '../testutils'; +import { nativeFramesIntegration } from '../../../src/js'; +import { NATIVE } from '../../../src/js/wrapper'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +import { mockFunction } from '../../testutils'; -jest.mock('../../src/js/wrapper', () => { +jest.mock('../../../src/js/wrapper', () => { return { NATIVE: { fetchNativeFrames: jest.fn(), @@ -29,11 +29,8 @@ describe('NativeFramesInstrumentation', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, - integrations: [ - new ReactNativeTracing({ - enableNativeFramesTracking: true, - }), - ], + enableNativeFramesTracking: true, + integrations: [nativeFramesIntegration()], }); client = new TestClient(options); setCurrentClient(client); From 699fda7b6e736a03d4e977a4555f11ae8baad38d Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 12:51:17 +0200 Subject: [PATCH 18/37] 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, From ad98ac0fc31d872fa11ab61ed8f7ee09ea9c68f4 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 14:13:19 +0200 Subject: [PATCH 19/37] fix tests --- src/js/tracing/integrations/nativeFrames.ts | 3 +- .../tracing/integrations/nativeframes.test.ts | 65 +++++++++++-------- test/tracing/reactnativetracing.test.ts | 33 ---------- 3 files changed, 38 insertions(+), 63 deletions(-) diff --git a/src/js/tracing/integrations/nativeFrames.ts b/src/js/tracing/integrations/nativeFrames.ts index 770ab115b4..1c00ffddd1 100644 --- a/src/js/tracing/integrations/nativeFrames.ts +++ b/src/js/tracing/integrations/nativeFrames.ts @@ -32,8 +32,6 @@ const _finishFrames: Map { NATIVE.enableNativeFramesTracking(); + // TODO: Ensure other integrations like ReactNativeTracing and ReactNavigation create spans after all integration are setup. client.on('spanStart', _onSpanStart); client.on('spanEnd', _onSpanFinish); logger.log('[ReactNativeTracing] Native frames instrumentation initialized.'); diff --git a/test/tracing/integrations/nativeframes.test.ts b/test/tracing/integrations/nativeframes.test.ts index 1a0e3bd2af..bcab5bcb2e 100644 --- a/test/tracing/integrations/nativeframes.test.ts +++ b/test/tracing/integrations/nativeframes.test.ts @@ -141,18 +141,18 @@ describe('NativeFramesInstrumentation', () => { await jest.runOnlyPendingTimersAsync(); await client.flush(); - expect(client.event!).toEqual( + expect(client.event!).toBeOneOf([ + expect.not.objectContaining>({ + measurements: expect.anything(), + }), expect.objectContaining>({ - measurements: expect.toBeOneOf([ - expect.not.objectContaining({ - frames_total: expect.any(Object), - frames_slow: expect.any(Object), - frames_frozen: expect.any(Object), - }), - undefined, - ]), + measurements: expect.not.objectContaining({ + frames_total: expect.any(Object), + frames_slow: expect.any(Object), + frames_frozen: expect.any(Object), + }), }), - ); + ]); }); it('does not set measurements on transactions without startFrames', async () => { @@ -171,15 +171,18 @@ describe('NativeFramesInstrumentation', () => { await jest.runOnlyPendingTimersAsync(); await client.flush(); - expect(client.event!).toEqual( + expect(client.event!).toBeOneOf([ + expect.not.objectContaining>({ + measurements: expect.anything(), + }), expect.objectContaining>({ - measurements: expect.not.objectContaining({ - frames_total: {}, - frames_slow: {}, - frames_frozen: {}, + measurements: expect.not.objectContaining({ + frames_total: expect.any(Object), + frames_slow: expect.any(Object), + frames_frozen: expect.any(Object), }), }), - ); + ]); }); it('does not set measurements on transactions without finishFrames', async () => { @@ -198,15 +201,18 @@ describe('NativeFramesInstrumentation', () => { await jest.runOnlyPendingTimersAsync(); await client.flush(); - expect(client.event!).toEqual( + expect(client.event!).toBeOneOf([ + expect.not.objectContaining>({ + measurements: expect.anything(), + }), expect.objectContaining>({ - measurements: expect.not.objectContaining({ - frames_total: {}, - frames_slow: {}, - frames_frozen: {}, + measurements: expect.not.objectContaining({ + frames_total: expect.any(Object), + frames_slow: expect.any(Object), + frames_frozen: expect.any(Object), }), }), - ); + ]); }); it('does not set measurements on a transaction event for which finishFrames times out.', async () => { @@ -230,14 +236,17 @@ describe('NativeFramesInstrumentation', () => { await jest.advanceTimersByTimeAsync(2100); // hardcoded final frames timeout 2000ms await client.flush(); - expect(client.event!).toEqual( + expect(client.event!).toBeOneOf([ + expect.not.objectContaining>({ + measurements: expect.anything(), + }), expect.objectContaining>({ - measurements: expect.not.objectContaining({ - frames_total: {}, - frames_slow: {}, - frames_frozen: {}, + measurements: expect.not.objectContaining({ + frames_total: expect.any(Object), + frames_slow: expect.any(Object), + frames_frozen: expect.any(Object), }), }), - ); + ]); }); }); diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index f01d049b60..1d840e0f31 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -212,39 +212,6 @@ describe('ReactNativeTracing', () => { }); }); - describe('Native Frames', () => { - let client: TestClient; - - beforeEach(() => { - client = setupTestClient(); - }); - - it('Initialize native frames instrumentation if flag is true', async () => { - const integration = new ReactNativeTracing({ - enableNativeFramesTracking: true, - }); - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - - expect(integration.nativeFramesInstrumentation).toBeDefined(); - expect(NATIVE.enableNativeFramesTracking).toBeCalledTimes(1); - }); - it('Does not initialize native frames instrumentation if flag is false', async () => { - const integration = new ReactNativeTracing({ - enableNativeFramesTracking: false, - }); - - integration.setup(client); - - await jest.advanceTimersByTimeAsync(500); - - expect(integration.nativeFramesInstrumentation).toBeUndefined(); - expect(NATIVE.disableNativeFramesTracking).toBeCalledTimes(1); - expect(NATIVE.fetchNativeFrames).not.toBeCalled(); - }); - }); - describe('Routing Instrumentation', () => { let client: TestClient; From f2b9abecb6d3816fea16c7b41ac0275a1bb92501 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 14:17:31 +0200 Subject: [PATCH 20/37] add changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1193f1210b..a7c045f2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,15 @@ }); ``` +- New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) + + ```js + Sentry.init({ + tracesSampleRate: 1.0, + enableNativeFramesTracking: true, // default true + }); + ``` + ### Fixes - Pass `sampleRate` option to the Android SDK ([#3979](https://github.com/getsentry/sentry-react-native/pull/3979)) From 89b2354517e960e219c9bf3841cdbcccc8e6e0bd Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 16:06:51 +0200 Subject: [PATCH 21/37] ref(tracing): Extract Stall Tracking to a standalone integration --- CHANGELOG.md | 13 +- src/js/integrations/default.ts | 4 + src/js/integrations/exports.ts | 1 + src/js/options.ts | 5 + src/js/sdk.tsx | 1 + .../{ => integrations}/stalltracking.ts | 311 +++++++++--------- src/js/tracing/reactnativetracing.ts | 14 - test/sdk.test.ts | 23 ++ .../stalltracking.background.test.ts | 46 +++ .../stalltracking.iteration.test.ts | 50 +++ .../stallTracking}/stalltracking.test.ts | 12 +- .../stallTracking}/stalltrackingutils.ts | 0 .../reactnavigation.stalltracking.test.ts | 9 +- test/tracing/stalltracking.background.test.ts | 46 --- test/tracing/stalltracking.iteration.test.ts | 50 --- 15 files changed, 294 insertions(+), 291 deletions(-) rename src/js/tracing/{ => integrations}/stalltracking.ts (53%) create mode 100644 test/tracing/integrations/stallTracking/stalltracking.background.test.ts create mode 100644 test/tracing/integrations/stallTracking/stalltracking.iteration.test.ts rename test/tracing/{ => integrations/stallTracking}/stalltracking.test.ts (96%) rename test/tracing/{ => integrations/stallTracking}/stalltrackingutils.ts (100%) delete mode 100644 test/tracing/stalltracking.background.test.ts delete mode 100644 test/tracing/stalltracking.iteration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c045f2ec..5c6b4dc708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Changes +- New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) +- New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) - 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. @@ -15,6 +17,8 @@ Sentry.init({ tracesSampleRate: 1.0, enableAppStartTracking: true, // default true + enableNativeFramesTracking: true, // default true + enableStallTracking: true, // default true integrations: [ Sentry.appStartIntegration({ standalone: false, // default false @@ -23,15 +27,6 @@ }); ``` -- New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - - ```js - Sentry.init({ - tracesSampleRate: 1.0, - enableNativeFramesTracking: true, // default true - }); - ``` - ### Fixes - Pass `sampleRate` option to the Android SDK ([#3979](https://github.com/getsentry/sentry-react-native/pull/3979)) diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index 03479ea2df..05868fa1c1 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -32,6 +32,7 @@ import { screenshotIntegration, sdkInfoIntegration, spotlightIntegration, + stallTrackingIntegration, viewHierarchyIntegration, } from './exports'; import { createReactNativeRewriteFrames } from './rewriteframes'; @@ -105,6 +106,9 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ if (hasTracingEnabled && options.enableNativeFramesTracking) { integrations.push(nativeFramesIntegration()); } + if (hasTracingEnabled && options.enableStallTracking) { + integrations.push(stallTrackingIntegration()); + } if (hasTracingEnabled && options.enableAutoPerformanceTracing) { integrations.push(new ReactNativeTracing()); } diff --git a/src/js/integrations/exports.ts b/src/js/integrations/exports.ts index 3335cb7128..0fc7d5a908 100644 --- a/src/js/integrations/exports.ts +++ b/src/js/integrations/exports.ts @@ -15,6 +15,7 @@ export { spotlightIntegration } from './spotlight'; export { mobileReplayIntegration } from '../replay/mobilereplay'; export { appStartIntegration } from '../tracing/integrations/appStart'; export { nativeFramesIntegration } from '../tracing/integrations/nativeFrames'; +export { stallTrackingIntegration } from '../tracing/integrations/stalltracking'; export { breadcrumbsIntegration, diff --git a/src/js/options.ts b/src/js/options.ts index 81bf8bd308..731f8848ff 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -206,6 +206,11 @@ export interface BaseReactNativeOptions { */ enableNativeFramesTracking?: boolean; + /** + * Track when and how long the JS event loop stalls for. Adds stalls as measurements to all transactions. + */ + enableStallTracking?: boolean; + /** * Options which are in beta, or otherwise not guaranteed to be stable. */ diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index 72e3ed3261..d8619bdfca 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -35,6 +35,7 @@ const DEFAULT_OPTIONS: ReactNativeOptions = { enableNdk: true, enableAppStartTracking: true, enableNativeFramesTracking: true, + enableStallTracking: true, }; /** diff --git a/src/js/tracing/stalltracking.ts b/src/js/tracing/integrations/stalltracking.ts similarity index 53% rename from src/js/tracing/stalltracking.ts rename to src/js/tracing/integrations/stalltracking.ts index 3e83de1bd2..a109cb7956 100644 --- a/src/js/tracing/stalltracking.ts +++ b/src/js/tracing/integrations/stalltracking.ts @@ -5,9 +5,11 @@ import { logger, timestampInSeconds } from '@sentry/utils'; import type { AppStateStatus } from 'react-native'; import { AppState } from 'react-native'; -import { STALL_COUNT, STALL_LONGEST_TIME, STALL_TOTAL_TIME } from '../measurements'; -import { isRootSpan } from '../utils/span'; -import { getLatestChildSpanEndTimestamp, isNearToNow, setSpanMeasurement } from './utils'; +import { STALL_COUNT, STALL_LONGEST_TIME, STALL_TOTAL_TIME } from '../../measurements'; +import { isRootSpan } from '../../utils/span'; +import { getLatestChildSpanEndTimestamp, isNearToNow, setSpanMeasurement } from '../utils'; + +const INTEGRATION_NAME = 'StallTracking'; export interface StallMeasurements extends Measurements { [STALL_COUNT]: { value: number; unit: MeasurementUnit }; @@ -15,14 +17,6 @@ export interface StallMeasurements extends Measurements { [STALL_LONGEST_TIME]: { value: number; unit: MeasurementUnit }; } -export type StallTrackingOptions = { - /** - * How long in milliseconds an event loop iteration can be delayed for before being considered a "stall." - * @default 100 - */ - minimumStallThreshold: number; -}; - /** Margin of error of 20ms */ const MARGIN_OF_ERROR_SECONDS = 0.02; /** How long between each iteration in the event loop tracker timeout */ @@ -37,25 +31,16 @@ const MAX_RUNNING_TRANSACTIONS = 10; * However, we modified the interval implementation to instead have a fixed loop timeout interval of `LOOP_TIMEOUT_INTERVAL_MS`. * We then would consider that iteration a stall when the total time for that interval to run is greater than `LOOP_TIMEOUT_INTERVAL_MS + minimumStallThreshold` */ -export class StallTrackingInstrumentation implements Integration { - public name: string = 'StallTrackingInstrumentation'; - - public isTracking: boolean = false; - - private _minimumStallThreshold: number; - - /** Total amount of time of all stalls that occurred during the current tracking session */ - private _totalStallTime: number = 0; - /** Total number of stalls that occurred during the current tracking session */ - private _stallCount: number = 0; - - /** The last timestamp the iteration ran in milliseconds */ - private _lastIntervalMs: number = 0; - private _timeout: ReturnType | null = null; - - private _isBackground: boolean = false; - - private _statsByRootSpan: Map< +export const stallTrackingIntegration = ({ + minimumStallThresholdMs = 50, +}: { + /** + * How long in milliseconds an event loop iteration can be delayed for before being considered a "stall." + * @default 50 + */ + minimumStallThresholdMs?: number; +} = {}): Integration => { + const statsByRootSpan: Map< Span, { longestStallTime: number; @@ -67,67 +52,108 @@ export class StallTrackingInstrumentation implements Integration { } > = new Map(); - public constructor(options: StallTrackingOptions = { minimumStallThreshold: 50 }) { - this._minimumStallThreshold = options.minimumStallThreshold; + const state: { + isTracking: boolean; + timeout: ReturnType | null; + isBackground: boolean; + /** Switch that enables the iteration once app moves from background to foreground. */ + backgroundEventListener: (appState: AppStateStatus) => void; + /** The last timestamp the iteration ran in milliseconds */ + lastIntervalMs: number; + /** Total amount of time of all stalls that occurred during the current tracking session */ + totalStallTime: number; + /** Total number of stalls that occurred during the current tracking session */ + stallCount: number; + /** + * Iteration of the stall tracking interval. Measures how long the timer strayed from its expected time of running, and how + * long the stall is for. + */ + iteration: () => void; + } = { + isTracking: false, + timeout: null, + isBackground: false, + lastIntervalMs: 0, + totalStallTime: 0, + stallCount: 0, + backgroundEventListener: (appState: AppStateStatus): void => { + if (appState === ('active' as AppStateStatus)) { + state.isBackground = false; + if (state.timeout != null) { + state.lastIntervalMs = timestampInSeconds() * 1000; + state.iteration(); + } + } else { + state.isBackground = true; + state.timeout !== null && clearTimeout(state.timeout); + } + }, + iteration: (): void => { + const now = timestampInSeconds() * 1000; + const totalTimeTaken = now - state.lastIntervalMs; + + if (totalTimeTaken >= LOOP_TIMEOUT_INTERVAL_MS + minimumStallThresholdMs) { + const stallTime = totalTimeTaken - LOOP_TIMEOUT_INTERVAL_MS; + state.stallCount += 1; + state.totalStallTime += stallTime; + + for (const [transaction, value] of statsByRootSpan.entries()) { + const longestStallTime = Math.max(value.longestStallTime ?? 0, stallTime); + + statsByRootSpan.set(transaction, { + ...value, + longestStallTime, + }); + } + } + + state.lastIntervalMs = now; - this._backgroundEventListener = this._backgroundEventListener.bind(this); - // Avoids throwing any error if using React Native on a environment that doesn't implement AppState. - if (AppState?.isAvailable) { - // eslint-disable-next-line @typescript-eslint/unbound-method - AppState.addEventListener('change', this._backgroundEventListener); - } - } + if (state.isTracking && !state.isBackground) { + state.timeout = setTimeout(state.iteration, LOOP_TIMEOUT_INTERVAL_MS); + } + }, + }; - /** - * @inheritDoc - */ - public setup(client: Client): void { - client.on('spanStart', this._onSpanStart); - client.on('spanEnd', this._onSpanEnd); - } + const setup = (client: Client): void => { + client.on('spanStart', _onSpanStart); + client.on('spanEnd', _onSpanEnd); + }; - /** - * Register a transaction as started. Starts stall tracking if not already running. - */ - private _onSpanStart = (rootSpan: Span): void => { + const _onSpanStart = (rootSpan: Span): void => { if (!isRootSpan(rootSpan)) { return; } - if (this._statsByRootSpan.has(rootSpan)) { + if (statsByRootSpan.has(rootSpan)) { logger.error( '[StallTracking] Tried to start stall tracking on a transaction already being tracked. Measurements might be lost.', ); return; } - this._startTracking(); - this._statsByRootSpan.set(rootSpan, { + _startTracking(); + statsByRootSpan.set(rootSpan, { longestStallTime: 0, atTimestamp: null, - atStart: this._getCurrentStats(rootSpan), + atStart: _getCurrentStats(rootSpan), }); - this._flushLeakedTransactions(); + _flushLeakedTransactions(); }; - /** - * Logs a transaction as finished. - * Stops stall tracking if no more transactions are running. - * @returns The stall measurements - */ - private _onSpanEnd = (rootSpan: Span): void => { + const _onSpanEnd = (rootSpan: Span): void => { if (!isRootSpan(rootSpan)) { - return this._onChildSpanEnd(rootSpan); + return _onChildSpanEnd(rootSpan); } - const transactionStats = this._statsByRootSpan.get(rootSpan); + const transactionStats = statsByRootSpan.get(rootSpan); if (!transactionStats) { // Transaction has been flushed out somehow, we return null. logger.log('[StallTracking] Stall measurements were not added to transaction due to exceeding the max count.'); - this._statsByRootSpan.delete(rootSpan); - this._shouldStopTracking(); + statsByRootSpan.delete(rootSpan); + _shouldStopTracking(); return; } @@ -138,7 +164,7 @@ export class StallTrackingInstrumentation implements Integration { let statsOnFinish: StallMeasurements | undefined; if (isNearToNow(endTimestamp)) { - statsOnFinish = this._getCurrentStats(rootSpan); + statsOnFinish = _getCurrentStats(rootSpan); } else { // The idleSpan in JS V8 is always trimmed to the last span's endTimestamp (timestamp). // The unfinished child spans are removed from the root span after the `spanEnd` event. @@ -161,8 +187,8 @@ export class StallTrackingInstrumentation implements Integration { } } - this._statsByRootSpan.delete(rootSpan); - this._shouldStopTracking(); + statsByRootSpan.delete(rootSpan); + _shouldStopTracking(); if (!statsOnFinish) { if (typeof endTimestamp !== 'undefined') { @@ -200,39 +226,20 @@ export class StallTrackingInstrumentation implements Integration { ); }; - /** - * Marks stalls - */ - private _onChildSpanEnd(childSpan: Span): void { + const _onChildSpanEnd = (childSpan: Span): void => { const rootSpan = getRootSpan(childSpan); const finalEndTimestamp = spanToJSON(childSpan).timestamp; if (finalEndTimestamp) { - this._markSpanFinish(rootSpan, finalEndTimestamp); - } - } - - /** - * Switch that enables the iteraction once app moves from background to foreground. - */ - private _backgroundEventListener(state: AppStateStatus): void { - if (state === ('active' as AppStateStatus)) { - this._isBackground = false; - if (this._timeout != null) { - this._lastIntervalMs = timestampInSeconds() * 1000; - this._iteration(); - } - } else { - this._isBackground = true; - this._timeout !== null && clearTimeout(this._timeout); + _markSpanFinish(rootSpan, finalEndTimestamp); } - } + }; /** * Logs the finish time of the span for use in `trimEnd: true` transactions. */ - private _markSpanFinish(rootSpan: Span, childSpanEndTime: number): void { - const previousStats = this._statsByRootSpan.get(rootSpan); + const _markSpanFinish = (rootSpan: Span, childSpanEndTime: number): void => { + const previousStats = statsByRootSpan.get(rootSpan); if (previousStats) { if (Math.abs(timestampInSeconds() - childSpanEndTime) > MARGIN_OF_ERROR_SECONDS) { logger.log( @@ -241,125 +248,109 @@ export class StallTrackingInstrumentation implements Integration { if (previousStats.atTimestamp && previousStats.atTimestamp.timestamp < childSpanEndTime) { // We also need to delete the stat for the last span, as the transaction would be trimmed to this span not the last one. - this._statsByRootSpan.set(rootSpan, { + statsByRootSpan.set(rootSpan, { ...previousStats, atTimestamp: null, }); } } else { - this._statsByRootSpan.set(rootSpan, { + statsByRootSpan.set(rootSpan, { ...previousStats, atTimestamp: { timestamp: childSpanEndTime, - stats: this._getCurrentStats(rootSpan), + stats: _getCurrentStats(rootSpan), }, }); } } - } + }; /** * Get the current stats for a transaction at a given time. */ - private _getCurrentStats(span: Span): StallMeasurements { + const _getCurrentStats = (span: Span): StallMeasurements => { return { - stall_count: { value: this._stallCount, unit: 'none' }, - stall_total_time: { value: this._totalStallTime, unit: 'millisecond' }, + stall_count: { value: state.stallCount, unit: 'none' }, + stall_total_time: { value: state.totalStallTime, unit: 'millisecond' }, stall_longest_time: { - value: this._statsByRootSpan.get(span)?.longestStallTime ?? 0, + value: statsByRootSpan.get(span)?.longestStallTime ?? 0, unit: 'millisecond', }, }; - } + }; /** * Start tracking stalls */ - private _startTracking(): void { - if (!this.isTracking) { - this.isTracking = true; - this._lastIntervalMs = Math.floor(timestampInSeconds() * 1000); + const _startTracking = (): void => { + if (!state.isTracking) { + state.isTracking = true; + state.lastIntervalMs = Math.floor(timestampInSeconds() * 1000); - this._iteration(); + state.iteration(); } - } + }; /** * Stops the stall tracking interval and calls reset(). */ - private _stopTracking(): void { - this.isTracking = false; + const _stopTracking = (): void => { + state.isTracking = false; - if (this._timeout !== null) { - clearTimeout(this._timeout); - this._timeout = null; + if (state.timeout !== null) { + clearTimeout(state.timeout); + state.timeout = null; } - this._reset(); - } + _reset(); + }; /** * Will stop tracking if there are no more transactions. */ - private _shouldStopTracking(): void { - if (this._statsByRootSpan.size === 0) { - this._stopTracking(); + const _shouldStopTracking = (): void => { + if (statsByRootSpan.size === 0) { + _stopTracking(); } - } + }; /** * Clears all the collected stats */ - private _reset(): void { - this._stallCount = 0; - this._totalStallTime = 0; - this._lastIntervalMs = 0; - this._statsByRootSpan.clear(); - } - - /** - * Iteration of the stall tracking interval. Measures how long the timer strayed from its expected time of running, and how - * long the stall is for. - */ - private _iteration(): void { - const now = timestampInSeconds() * 1000; - const totalTimeTaken = now - this._lastIntervalMs; - - if (totalTimeTaken >= LOOP_TIMEOUT_INTERVAL_MS + this._minimumStallThreshold) { - const stallTime = totalTimeTaken - LOOP_TIMEOUT_INTERVAL_MS; - this._stallCount += 1; - this._totalStallTime += stallTime; - - for (const [transaction, value] of this._statsByRootSpan.entries()) { - const longestStallTime = Math.max(value.longestStallTime ?? 0, stallTime); - - this._statsByRootSpan.set(transaction, { - ...value, - longestStallTime, - }); - } - } - - this._lastIntervalMs = now; - - if (this.isTracking && !this._isBackground) { - this._timeout = setTimeout(this._iteration.bind(this), LOOP_TIMEOUT_INTERVAL_MS); - } - } + const _reset = (): void => { + state.stallCount = 0; + state.totalStallTime = 0; + state.lastIntervalMs = 0; + statsByRootSpan.clear(); + }; /** * Deletes leaked transactions (Earliest transactions when we have more than MAX_RUNNING_TRANSACTIONS transactions.) */ - private _flushLeakedTransactions(): void { - if (this._statsByRootSpan.size > MAX_RUNNING_TRANSACTIONS) { + const _flushLeakedTransactions = (): void => { + if (statsByRootSpan.size > MAX_RUNNING_TRANSACTIONS) { let counter = 0; - const len = this._statsByRootSpan.size - MAX_RUNNING_TRANSACTIONS; - const transactions = this._statsByRootSpan.keys(); + const len = statsByRootSpan.size - MAX_RUNNING_TRANSACTIONS; + const transactions = statsByRootSpan.keys(); for (const t of transactions) { if (counter >= len) break; counter += 1; - this._statsByRootSpan.delete(t); + statsByRootSpan.delete(t); } } + }; + + // Avoids throwing any error if using React Native on a environment that doesn't implement AppState. + if (AppState?.isAvailable) { + // eslint-disable-next-line @typescript-eslint/unbound-method + AppState.addEventListener('change', state.backgroundEventListener); } -} + + return { + name: INTEGRATION_NAME, + setup, + + /** For testing only @private */ + _internalState: state, + } as Integration; +}; diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 8c2e5ef1ac..3d575b150c 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -21,7 +21,6 @@ import { onlySampleIfChildSpans, onThisSpanEnd, } from './onSpanEndUtils'; -import { StallTrackingInstrumentation } from './stalltracking'; import type { BeforeNavigate } from './types'; const SCOPE_SPAN_FIELD = '_sentrySpan'; @@ -89,11 +88,6 @@ export interface ReactNativeTracingOptions extends RequestInstrumentationOptions */ beforeNavigate: BeforeNavigate; - /** - * Track when and how long the JS event loop stalls for. Adds stalls as measurements to all transactions. - */ - enableStallTracking: boolean; - /** * Trace User Interaction events like touch and gestures. */ @@ -110,7 +104,6 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { finalTimeoutMs: 600000, ignoreEmptyBackNavigationTransactions: true, beforeNavigate: context => context, - enableStallTracking: true, enableUserInteractionTracing: false, }; @@ -131,7 +124,6 @@ export class ReactNativeTracing implements Integration { /** ReactNativeTracing options */ public options: ReactNativeTracingOptions; - public stallTrackingInstrumentation?: StallTrackingInstrumentation; public useAppStartWithProfiler: boolean = false; private _inflightInteractionTransaction?: Span; @@ -183,7 +175,6 @@ export class ReactNativeTracing implements Integration { // eslint-disable-next-line deprecation/deprecation tracePropagationTargets: thisOptionsTracePropagationTargets, routingInstrumentation, - enableStallTracking, } = this.options; const clientOptionsTracePropagationTargets = clientOptions && clientOptions.tracePropagationTargets; @@ -192,11 +183,6 @@ export class ReactNativeTracing implements Integration { (this._hasSetTracePropagationTargets && thisOptionsTracePropagationTargets) || DEFAULT_TRACE_PROPAGATION_TARGETS; - if (enableStallTracking) { - this.stallTrackingInstrumentation = new StallTrackingInstrumentation(); - this.stallTrackingInstrumentation.setup(client); - } - if (routingInstrumentation) { routingInstrumentation.registerRoutingInstrumentation( this._onRouteWillChange.bind(this), diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 01c5c7ed5c..2d90123811 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -456,6 +456,29 @@ describe('Tests the SDK functionality', () => { expectNotIntegration('NativeFrames'); }); + it('no stall tracking integration by default', () => { + init({}); + + expectNotIntegration('StallTracking'); + }); + + it('when tracing enabled stall tracking integration added by default', () => { + init({ + tracesSampleRate: 0.5, + }); + + expectIntegration('StallTracking'); + }); + + it('when tracing enabled and stall tracking disabled the integration is not added', () => { + init({ + tracesSampleRate: 0.5, + enableStallTracking: false, + }); + + expectNotIntegration('StallTracking'); + }); + it('no default integrations', () => { init({ defaultIntegrations: false, diff --git a/test/tracing/integrations/stallTracking/stalltracking.background.test.ts b/test/tracing/integrations/stallTracking/stalltracking.background.test.ts new file mode 100644 index 0000000000..c8174e3c3f --- /dev/null +++ b/test/tracing/integrations/stallTracking/stalltracking.background.test.ts @@ -0,0 +1,46 @@ +import type { AppStateStatus } from 'react-native'; + +import { stallTrackingIntegration } from '../../../../src/js/tracing/integrations/stalltracking'; + +describe('BackgroundEventListener', () => { + it('Stall tracking should set _isBackground to false, update _lastIntervalMs, and call _iteration when state is active and _timeout is not null', () => { + const stallTracking = stallTrackingIntegration(); + const LOOP_TIMEOUT_INTERVAL_MS = 500; // Change this value based on your actual interval value + const currentTime = Date.now(); + stallTracking['_internalState']['lastIntervalMs'] = currentTime; + stallTracking['_internalState']['timeout'] = setTimeout(() => {}, LOOP_TIMEOUT_INTERVAL_MS); // Create a fake timeout to simulate a running interval + stallTracking['_internalState']['isBackground'] = true; + jest.useFakeTimers(); // Enable fake timers to control timeouts + stallTracking['_internalState']['backgroundEventListener']('active' as AppStateStatus); + // Check if _isBackground is set to false and _lastIntervalMs is updated correctly + expect(stallTracking['_internalState']['isBackground']).toBe(false); + expect(stallTracking['_internalState']['lastIntervalMs']).toBeGreaterThanOrEqual(currentTime); + jest.runOnlyPendingTimers(); // Fast-forward the timer to execute the timeout function + }); + it('Stall tracking should set _isBackground to true when state is not active', () => { + const stallTracking = stallTrackingIntegration(); + stallTracking['_internalState']['isBackground'] = false; + stallTracking['_internalState']['backgroundEventListener']('background' as AppStateStatus); + // Check if _isBackground is set to true + expect(stallTracking['_internalState']['isBackground']).toBe(true); + }); + it('Stall tracking should not call _iteration when state is active but _timeout is null', () => { + const stallTracking = stallTrackingIntegration(); + stallTracking['_internalState']['timeout'] = null; + // Mock _iteration + stallTracking['_internalState']['iteration'] = jest.fn(); + jest.useFakeTimers(); // Enable fake timers to control timeouts + stallTracking['_internalState']['backgroundEventListener']('active' as AppStateStatus); + + expect(stallTracking['_internalState']['iteration']).not.toBeCalled(); + }); + it('Stall tracking should call _iteration when state is active and _timeout is defined', () => { + const stallTracking = stallTrackingIntegration(); + stallTracking['_internalState']['timeout'] = setTimeout(() => {}, 500); + // Mock _iteration + stallTracking['_internalState']['iteration'] = jest.fn(); // Create a fake timeout to simulate a running interval + jest.useFakeTimers(); // Enable fake timers to control timeouts + stallTracking['_internalState']['backgroundEventListener']('active' as AppStateStatus); + expect(stallTracking['_internalState']['iteration']).toBeCalled(); + }); +}); diff --git a/test/tracing/integrations/stallTracking/stalltracking.iteration.test.ts b/test/tracing/integrations/stallTracking/stalltracking.iteration.test.ts new file mode 100644 index 0000000000..95b8506a09 --- /dev/null +++ b/test/tracing/integrations/stallTracking/stalltracking.iteration.test.ts @@ -0,0 +1,50 @@ +import { stallTrackingIntegration } from '../../../../src/js/tracing/integrations/stalltracking'; + +describe('Iteration', () => { + it('Stall tracking does not set _timeout when isTracking is false', () => { + const stallTracking = stallTrackingIntegration(); + stallTracking['isTracking'] = false; + stallTracking['_internalState']['isBackground'] = false; + stallTracking['_internalState']['lastIntervalMs'] = Date.now() - 1000; // Force a timeout + jest.useFakeTimers(); + // Invokes the private _interaction function. + stallTracking['_internalState']['iteration'](); + expect(stallTracking['_internalState']['timeout']).toBeNull(); + }); + it('Stall tracking does not set _timeout when isBackground is true', () => { + const stallTracking = stallTrackingIntegration(); + stallTracking['isTracking'] = true; + stallTracking['_internalState']['isBackground'] = true; + stallTracking['_internalState']['lastIntervalMs'] = Date.now() - 1000; // Force a timeout + jest.useFakeTimers(); + // Invokes the private _interaction function. + stallTracking['_internalState']['iteration'](); + expect(stallTracking['_internalState']['timeout']).toBeNull(); + }); + it('Stall tracking should set _timeout when isTracking is true and isBackground false', () => { + const stallTracking = stallTrackingIntegration(); + stallTracking['isTracking'] = true; + stallTracking['_internalState']['isBackground'] = false; + jest.useFakeTimers(); + stallTracking['_internalState']['lastIntervalMs'] = Date.now(); // Force a timeout + // Invokes the private _interaction function. + stallTracking['_internalState']['iteration'](); + expect(stallTracking['_internalState']['timeout']).toBeDefined(); + }); + it('Stall tracking should update _stallCount and _totalStallTime when timeout condition is met', () => { + const stallTracking = stallTrackingIntegration(); + const LOOP_TIMEOUT_INTERVAL_MS = 50; + const _minimumStallThreshold = 100; + // Call _iteration with totalTimeTaken >= LOOP_TIMEOUT_INTERVAL_MS + _minimumStallThreshold + const totalTimeTaken = LOOP_TIMEOUT_INTERVAL_MS + _minimumStallThreshold; + jest.useFakeTimers(); + stallTracking['_internalState']['lastIntervalMs'] = Date.now() - totalTimeTaken; + stallTracking['_internalState']['statsByTransaction'] = new Map(); + stallTracking['_internalState']['iteration'](); + // Check if _stallCount and _totalStallTime have been updated as expected. + expect(stallTracking['_internalState']['stallCount']).toBe(1); + expect(stallTracking['_internalState']['totalStallTime']).toBeGreaterThanOrEqual( + Math.round(totalTimeTaken - LOOP_TIMEOUT_INTERVAL_MS), + ); + }); +}); diff --git a/test/tracing/stalltracking.test.ts b/test/tracing/integrations/stallTracking/stalltracking.test.ts similarity index 96% rename from test/tracing/stalltracking.test.ts rename to test/tracing/integrations/stallTracking/stalltracking.test.ts index f0042a5af8..a59d74bb7d 100644 --- a/test/tracing/stalltracking.test.ts +++ b/test/tracing/integrations/stallTracking/stalltracking.test.ts @@ -10,8 +10,8 @@ import { import type { Span } from '@sentry/types'; import { timestampInSeconds } from '@sentry/utils'; -import { ReactNativeTracing } from '../../src/js'; -import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; +import { stallTrackingIntegration } from '../../../../src/js/tracing/integrations/stalltracking'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; import { expectNonZeroStallMeasurements, expectStallMeasurements } from './stalltrackingutils'; jest.useFakeTimers({ advanceTimers: true }); @@ -35,14 +35,10 @@ describe('StallTracking', () => { getIsolationScope().clear(); getGlobalScope().clear(); - const rnTracing = new ReactNativeTracing({ - enableStallTracking: true, - enableNativeFramesTracking: false, - }); - const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, - integrations: [rnTracing], + enableStallTracking: true, + integrations: [stallTrackingIntegration()], enableAppStartTracking: false, }); client = new TestClient(options); diff --git a/test/tracing/stalltrackingutils.ts b/test/tracing/integrations/stallTracking/stalltrackingutils.ts similarity index 100% rename from test/tracing/stalltrackingutils.ts rename to test/tracing/integrations/stallTracking/stalltrackingutils.ts diff --git a/test/tracing/reactnavigation.stalltracking.test.ts b/test/tracing/reactnavigation.stalltracking.test.ts index b8a041aa06..2c01838016 100644 --- a/test/tracing/reactnavigation.stalltracking.test.ts +++ b/test/tracing/reactnavigation.stalltracking.test.ts @@ -6,11 +6,12 @@ jest.mock('../../src/js/tracing/utils', () => ({ import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, startSpanManual } from '@sentry/core'; import { ReactNativeTracing, ReactNavigationInstrumentation } from '../../src/js'; +import { stallTrackingIntegration } from '../../src/js/tracing/integrations/stalltracking'; import { isNearToNow } from '../../src/js/tracing/utils'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; +import { expectStallMeasurements } from './integrations/stallTracking/stalltrackingutils'; import { createMockNavigationAndAttachTo } from './reactnavigationutils'; -import { expectStallMeasurements } from './stalltrackingutils'; jest.useFakeTimers({ advanceTimers: 1 }); @@ -30,14 +31,14 @@ describe('StallTracking with ReactNavigation', () => { const rnTracing = new ReactNativeTracing({ routingInstrumentation: rnavigation, - enableStallTracking: true, - enableNativeFramesTracking: false, }); const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, - integrations: [rnTracing], + integrations: [stallTrackingIntegration(), rnTracing], + enableNativeFramesTracking: false, enableAppStartTracking: false, + enableStallTracking: true, }); client = new TestClient(options); setCurrentClient(client); diff --git a/test/tracing/stalltracking.background.test.ts b/test/tracing/stalltracking.background.test.ts deleted file mode 100644 index 887fd90a56..0000000000 --- a/test/tracing/stalltracking.background.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { AppStateStatus } from 'react-native'; - -import { StallTrackingInstrumentation } from '../../src/js/tracing/stalltracking'; - -describe('BackgroundEventListener', () => { - it('Stall tracking should set _isBackground to false, update _lastIntervalMs, and call _iteration when state is active and _timeout is not null', () => { - const stallTracking = new StallTrackingInstrumentation(); - const LOOP_TIMEOUT_INTERVAL_MS = 500; // Change this value based on your actual interval value - const currentTime = Date.now(); - stallTracking['_lastIntervalMs'] = currentTime; - stallTracking['_timeout'] = setTimeout(() => {}, LOOP_TIMEOUT_INTERVAL_MS); // Create a fake timeout to simulate a running interval - stallTracking['_isBackground'] = true; - jest.useFakeTimers(); // Enable fake timers to control timeouts - stallTracking['_backgroundEventListener']('active' as AppStateStatus); - // Check if _isBackground is set to false and _lastIntervalMs is updated correctly - expect(stallTracking['_isBackground']).toBe(false); - expect(stallTracking['_lastIntervalMs']).toBeGreaterThanOrEqual(currentTime); - jest.runOnlyPendingTimers(); // Fast-forward the timer to execute the timeout function - }); - it('Stall tracking should set _isBackground to true when state is not active', () => { - const stallTracking = new StallTrackingInstrumentation(); - stallTracking['_isBackground'] = false; - stallTracking['_backgroundEventListener']('background' as AppStateStatus); - // Check if _isBackground is set to true - expect(stallTracking['_isBackground']).toBe(true); - }); - it('Stall tracking should not call _iteration when state is active but _timeout is null', () => { - const stallTracking = new StallTrackingInstrumentation(); - stallTracking['_timeout'] = null; - // Mock _iteration - stallTracking['_iteration'] = jest.fn(); - jest.useFakeTimers(); // Enable fake timers to control timeouts - stallTracking['_backgroundEventListener']('active' as AppStateStatus); - - expect(stallTracking['_iteration']).not.toBeCalled(); - }); - it('Stall tracking should call _iteration when state is active and _timeout is defined', () => { - const stallTracking = new StallTrackingInstrumentation(); - stallTracking['_timeout'] = setTimeout(() => {}, 500); - // Mock _iteration - stallTracking['_iteration'] = jest.fn(); // Create a fake timeout to simulate a running interval - jest.useFakeTimers(); // Enable fake timers to control timeouts - stallTracking['_backgroundEventListener']('active' as AppStateStatus); - expect(stallTracking['_iteration']).toBeCalled(); - }); -}); diff --git a/test/tracing/stalltracking.iteration.test.ts b/test/tracing/stalltracking.iteration.test.ts deleted file mode 100644 index 5eeb02f240..0000000000 --- a/test/tracing/stalltracking.iteration.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { StallTrackingInstrumentation } from '../../src/js/tracing/stalltracking'; - -describe('Iteration', () => { - it('Stall tracking does not set _timeout when isTracking is false', () => { - const stallTracking = new StallTrackingInstrumentation(); - stallTracking['isTracking'] = false; - stallTracking['_isBackground'] = false; - stallTracking['_lastIntervalMs'] = Date.now() - 1000; // Force a timeout - jest.useFakeTimers(); - // Invokes the private _interaction function. - stallTracking['_iteration'](); - expect(stallTracking['_timeout']).toBeNull(); - }); - it('Stall tracking does not set _timeout when isBackground is true', () => { - const stallTracking = new StallTrackingInstrumentation(); - stallTracking['isTracking'] = true; - stallTracking['_isBackground'] = true; - stallTracking['_lastIntervalMs'] = Date.now() - 1000; // Force a timeout - jest.useFakeTimers(); - // Invokes the private _interaction function. - stallTracking['_iteration'](); - expect(stallTracking['_timeout']).toBeNull(); - }); - it('Stall tracking should set _timeout when isTracking is true and isBackground false', () => { - const stallTracking = new StallTrackingInstrumentation(); - stallTracking['isTracking'] = true; - stallTracking['_isBackground'] = false; - jest.useFakeTimers(); - stallTracking['_lastIntervalMs'] = Date.now(); // Force a timeout - // Invokes the private _interaction function. - stallTracking['_iteration'](); - expect(stallTracking['_timeout']).toBeDefined(); - }); - it('Stall tracking should update _stallCount and _totalStallTime when timeout condition is met', () => { - const stallTracking = new StallTrackingInstrumentation(); - const LOOP_TIMEOUT_INTERVAL_MS = 50; - const _minimumStallThreshold = 100; - // Call _iteration with totalTimeTaken >= LOOP_TIMEOUT_INTERVAL_MS + _minimumStallThreshold - const totalTimeTaken = LOOP_TIMEOUT_INTERVAL_MS + _minimumStallThreshold; - jest.useFakeTimers(); - stallTracking['_lastIntervalMs'] = Date.now() - totalTimeTaken; - stallTracking['_statsByTransaction'] = new Map(); - stallTracking['_iteration'](); - // Check if _stallCount and _totalStallTime have been updated as expected. - expect(stallTracking['_stallCount']).toBe(1); - expect(stallTracking['_totalStallTime']).toBeGreaterThanOrEqual( - Math.round(totalTimeTaken - LOOP_TIMEOUT_INTERVAL_MS), - ); - }); -}); From 4f3ca7b2ab0780d8f33cd582a503b114d63358c3 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 18:00:06 +0200 Subject: [PATCH 22/37] misc(tracing): Remove ReactNativeTracing deprecated options --- CHANGELOG.md | 2 ++ src/js/tracing/reactnativetracing.ts | 17 ++------- test/tracing/reactnativetracing.test.ts | 48 ------------------------- 3 files changed, 4 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6b4dc708..79a8b0e65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Changes +- Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) +- Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) - New App Start Integration ([#3852](https://github.com/getsentry/sentry-react-native/pull/3852)) diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 3d575b150c..67e4af1a04 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -98,8 +98,6 @@ const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { ...defaultRequestInstrumentationOptions, - idleTimeout: 1000, - maxTransactionDuration: 600, idleTimeoutMs: 1000, finalTimeoutMs: 600000, ignoreEmptyBackNavigationTransactions: true, @@ -143,19 +141,8 @@ export class ReactNativeTracing implements Integration { this.options = { ...defaultReactNativeTracingOptions, ...options, - finalTimeoutMs: - options.finalTimeoutMs ?? - // eslint-disable-next-line deprecation/deprecation - (typeof options.maxTransactionDuration === 'number' - ? // eslint-disable-next-line deprecation/deprecation - options.maxTransactionDuration * 1000 - : undefined) ?? - defaultReactNativeTracingOptions.finalTimeoutMs, - idleTimeoutMs: - options.idleTimeoutMs ?? - // eslint-disable-next-line deprecation/deprecation - options.idleTimeout ?? - defaultReactNativeTracingOptions.idleTimeoutMs, + finalTimeoutMs: options.finalTimeoutMs ?? defaultReactNativeTracingOptions.finalTimeoutMs, + idleTimeoutMs: options.idleTimeoutMs ?? defaultReactNativeTracingOptions.idleTimeoutMs, }; } diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 1d840e0f31..209c343cac 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -292,54 +292,6 @@ describe('ReactNativeTracing', () => { }); }); }); - describe('Handling deprecated options', () => { - test('finalTimeoutMs overrides maxTransactionDuration', () => { - const tracing = new ReactNativeTracing({ - finalTimeoutMs: 123000, - maxTransactionDuration: 456, - }); - expect(tracing.options.finalTimeoutMs).toBe(123000); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.maxTransactionDuration).toBe(456); - }); - test('maxTransactionDuration translates to finalTimeoutMs', () => { - const tracing = new ReactNativeTracing({ - maxTransactionDuration: 123, - }); - expect(tracing.options.finalTimeoutMs).toBe(123000); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.maxTransactionDuration).toBe(123); - }); - test('if none maxTransactionDuration and finalTimeoutMs is specified use default', () => { - const tracing = new ReactNativeTracing({}); - expect(tracing.options.finalTimeoutMs).toBe(600000); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.maxTransactionDuration).toBe(600); - }); - test('idleTimeoutMs overrides idleTimeout', () => { - const tracing = new ReactNativeTracing({ - idleTimeoutMs: 123, - idleTimeout: 456, - }); - expect(tracing.options.idleTimeoutMs).toBe(123); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.idleTimeout).toBe(456); - }); - test('idleTimeout translates to idleTimeoutMs', () => { - const tracing = new ReactNativeTracing({ - idleTimeout: 123, - }); - expect(tracing.options.idleTimeoutMs).toBe(123); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.idleTimeout).toBe(123); - }); - test('if none idleTimeout and idleTimeoutMs is specified use default', () => { - const tracing = new ReactNativeTracing({}); - expect(tracing.options.idleTimeoutMs).toBe(1000); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.idleTimeout).toBe(1000); - }); - }); describe('User Interaction Tracing', () => { let client: TestClient; From 5c12e5c74c34a9b1f86d795413f32a6fdbf41176 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 18:01:38 +0200 Subject: [PATCH 23/37] fix changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a8b0e65e..71ec9404c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ ### Changes -- Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) -- Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) +- Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) +- Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) - New App Start Integration ([#3852](https://github.com/getsentry/sentry-react-native/pull/3852)) From a111bdf3c4230b2b908c0bf722381706e955f11c Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 18:59:39 +0200 Subject: [PATCH 24/37] ref(tracing): Extract UserInteractionTracing as standalone interaction --- src/js/options.ts | 5 + src/js/sdk.tsx | 1 + src/js/touchevents.tsx | 3 +- src/js/tracing/gesturetracing.ts | 8 +- .../tracing/integrations/userInteraction.ts | 100 ++++++++ src/js/tracing/origin.ts | 1 + src/js/tracing/reactnativetracing.ts | 229 ++---------------- src/js/tracing/span.ts | 111 +++++++++ 8 files changed, 248 insertions(+), 210 deletions(-) create mode 100644 src/js/tracing/integrations/userInteraction.ts create mode 100644 src/js/tracing/origin.ts create mode 100644 src/js/tracing/span.ts diff --git a/src/js/options.ts b/src/js/options.ts index 731f8848ff..a5cded348f 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -211,6 +211,11 @@ export interface BaseReactNativeOptions { */ enableStallTracking?: boolean; + /** + * Trace User Interaction events like touch and gestures. + */ + enableUserInteractionTracing?: boolean; + /** * Options which are in beta, or otherwise not guaranteed to be stable. */ diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index d8619bdfca..1e0a60f29c 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -36,6 +36,7 @@ const DEFAULT_OPTIONS: ReactNativeOptions = { enableAppStartTracking: true, enableNativeFramesTracking: true, enableStallTracking: true, + enableUserInteractionTracing: false, }; /** diff --git a/src/js/touchevents.tsx b/src/js/touchevents.tsx index c9bf18a000..45908a9054 100644 --- a/src/js/touchevents.tsx +++ b/src/js/touchevents.tsx @@ -7,6 +7,7 @@ import { StyleSheet, View } from 'react-native'; import { createIntegration } from './integrations/factory'; import type { ReactNativeTracing } from './tracing'; +import { startUserInteractionSpan } from './tracing/integrations/userInteraction'; import { UI_ACTION_TOUCH } from './tracing/ops'; export type TouchEventBoundaryProps = { @@ -200,7 +201,7 @@ class TouchEventBoundary extends React.Component { this._logTouchEvent(touchPath, label); } - this._tracingIntegration?.startUserInteractionSpan({ + startUserInteractionSpan({ elementId: label, op: UI_ACTION_TOUCH, }); diff --git a/src/js/tracing/gesturetracing.ts b/src/js/tracing/gesturetracing.ts index f7965dab1f..ffa2e38df6 100644 --- a/src/js/tracing/gesturetracing.ts +++ b/src/js/tracing/gesturetracing.ts @@ -1,9 +1,9 @@ -import { addBreadcrumb, getClient } from '@sentry/core'; +import { addBreadcrumb } from '@sentry/core'; import type { Breadcrumb } from '@sentry/types'; import { logger } from '@sentry/utils'; +import { startUserInteractionSpan } from './integrations/userInteraction'; import { UI_ACTION } from './ops'; -import type { ReactNativeTracing } from './reactnativetracing'; export const DEFAULT_BREADCRUMB_CATEGORY = 'gesture'; export const DEFAULT_BREADCRUMB_TYPE = 'user'; @@ -69,9 +69,7 @@ export function sentryTraceGesture( const originalOnBegin = gestureCandidate.handlers.onBegin; (gesture as unknown as Required).handlers.onBegin = (event: GestureEvent) => { - getClient() - ?.getIntegrationByName('ReactNativeTracing') - ?.startUserInteractionSpan({ elementId: label, op: `${UI_ACTION}.${name}` }); + startUserInteractionSpan({ elementId: label, op: `${UI_ACTION}.${name}` }); addGestureBreadcrumb(`Gesture ${label} begin.`, { event, name }); diff --git a/src/js/tracing/integrations/userInteraction.ts b/src/js/tracing/integrations/userInteraction.ts new file mode 100644 index 0000000000..7bf47addb2 --- /dev/null +++ b/src/js/tracing/integrations/userInteraction.ts @@ -0,0 +1,100 @@ +import { + getActiveSpan, + getClient, + getCurrentScope, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startIdleSpan, +} from '@sentry/core'; +import type { Integration, Span, StartSpanOptions } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import type { ReactNativeClientOptions } from '../../options'; +import { onlySampleIfChildSpans } from '../onSpanEndUtils'; +import { SPAN_ORIGIN_AUTO_INTERACTION } from '../origin'; +import { getCurrentReactNativeTracingIntegration } from '../reactnativetracing'; +import { clearActiveSpanFromScope, isSentryInteractionSpan } from '../span'; + +const INTEGRATION_NAME = 'UserInteraction'; + +export const userInteractionIntegration = (): Integration => { + return { + name: INTEGRATION_NAME, + }; +}; + +/** + * Starts a new transaction for a user interaction. + * @param userInteractionId Consists of `op` representation UI Event and `elementId` unique element identifier on current screen. + */ +export const startUserInteractionSpan = (userInteractionId: { + elementId: string | undefined; + op: string; +}): Span | undefined => { + const client = getClient(); + if (!client) { + return undefined; + } + + const tracing = getCurrentReactNativeTracingIntegration(); + if (!tracing) { + logger.log(`[${INTEGRATION_NAME}] Tracing integration is not available. Can not start user interaction span.`); + return undefined; + } + + const options = client.getOptions() as ReactNativeClientOptions; + const { elementId, op } = userInteractionId; + if (!options.enableUserInteractionTracing) { + logger.log(`[${INTEGRATION_NAME}] User Interaction Tracing is disabled.`); + return undefined; + } + if (!elementId) { + logger.log(`[${INTEGRATION_NAME}] User Interaction Tracing can not create transaction with undefined elementId.`); + return undefined; + } + if (!tracing.currentRoute) { + logger.log(`[${INTEGRATION_NAME}] User Interaction Tracing can not create transaction without a current route.`); + return undefined; + } + + const activeTransaction = getActiveSpan(); + const activeTransactionIsNotInteraction = activeTransaction && !isSentryInteractionSpan(activeTransaction); + if (activeTransaction && activeTransactionIsNotInteraction) { + logger.warn( + `[${INTEGRATION_NAME}] Did not create ${op} transaction because active transaction ${ + spanToJSON(activeTransaction).description + } exists on the scope.`, + ); + return undefined; + } + + const name = `${tracing.currentRoute}.${elementId}`; + if ( + activeTransaction && + spanToJSON(activeTransaction).description === name && + spanToJSON(activeTransaction).op === op + ) { + logger.warn( + `[${INTEGRATION_NAME}] Did not create ${op} transaction because it the same transaction ${ + spanToJSON(activeTransaction).description + } already exists on the scope.`, + ); + return undefined; + } + + const scope = getCurrentScope(); + const context: StartSpanOptions = { + name, + op, + scope, + }; + clearActiveSpanFromScope(scope); + const newSpan = startIdleSpan(context, { + idleTimeout: tracing.options.idleTimeoutMs, + finalTimeout: tracing.options.finalTimeoutMs, + }); + newSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_INTERACTION); + onlySampleIfChildSpans(client, newSpan); + logger.log(`[${INTEGRATION_NAME}] User Interaction Tracing Created ${op} transaction ${name}.`); + return newSpan; +}; diff --git a/src/js/tracing/origin.ts b/src/js/tracing/origin.ts new file mode 100644 index 0000000000..830e7f158f --- /dev/null +++ b/src/js/tracing/origin.ts @@ -0,0 +1 @@ +export const SPAN_ORIGIN_AUTO_INTERACTION = 'auto.interaction'; diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 67e4af1a04..492d31c686 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -1,50 +1,15 @@ /* eslint-disable max-lines */ import type { RequestInstrumentationOptions } from '@sentry/browser'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from '@sentry/browser'; -import { - getActiveSpan, - getCurrentScope, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SentryNonRecordingSpan, - SPAN_STATUS_ERROR, - spanToJSON, - startIdleSpan, -} from '@sentry/core'; -import type { Client, Event, Integration, PropagationContext, Scope, Span, StartSpanOptions } from '@sentry/types'; -import { logger, uuid4 } from '@sentry/utils'; +import { getClient, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON } from '@sentry/core'; +import type { Client, Event, Integration, Span } from '@sentry/types'; +import { logger } from '@sentry/utils'; import type { RoutingInstrumentationInstance } from '../tracing/routingInstrumentation'; -import { - adjustTransactionDuration, - cancelInBackground, - ignoreEmptyBackNavigation, - onlySampleIfChildSpans, - onThisSpanEnd, -} from './onSpanEndUtils'; +import { startIdleNavigationSpan } from './span'; import type { BeforeNavigate } from './types'; -const SCOPE_SPAN_FIELD = '_sentrySpan'; - -type ScopeWithMaybeSpan = Scope & { - [SCOPE_SPAN_FIELD]?: Span; -}; - -function clearActiveSpanFromScope(scope: ScopeWithMaybeSpan): void { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete scope[SCOPE_SPAN_FIELD]; -} - export interface ReactNativeTracingOptions extends RequestInstrumentationOptions { - /** - * @deprecated Replaced by idleTimeoutMs - */ - idleTimeout: number; - - /** - * @deprecated Replaced by maxTransactionDurationMs - */ - maxTransactionDuration: number; - /** * The time to wait in ms until the transaction will be finished. The transaction will use the end timestamp of * the last finished span as the endtime for the transaction. @@ -87,11 +52,6 @@ export interface ReactNativeTracingOptions extends RequestInstrumentationOptions * @returns A (potentially) modified context object, with `sampled = false` if the transaction should be dropped. */ beforeNavigate: BeforeNavigate; - - /** - * Trace User Interaction events like touch and gestures. - */ - enableUserInteractionTracing: boolean; } const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; @@ -102,7 +62,6 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { finalTimeoutMs: 600000, ignoreEmptyBackNavigationTransactions: true, beforeNavigate: context => context, - enableUserInteractionTracing: false, }; /** @@ -124,12 +83,10 @@ export class ReactNativeTracing implements Integration { public useAppStartWithProfiler: boolean = false; - private _inflightInteractionTransaction?: Span; + public currentRoute?: string; - private _currentRoute?: string; private _hasSetTracePropagationTargets: boolean; private _currentViewName: string | undefined; - private _client: Client | undefined; public constructor(options: Partial = {}) { this._hasSetTracePropagationTargets = !!( @@ -150,7 +107,6 @@ export class ReactNativeTracing implements Integration { * Registers routing and request instrumentation. */ public setup(client: Client): void { - this._client = client; const clientOptions = client && client.getOptions(); // eslint-disable-next-line @typescript-eslint/unbound-method @@ -198,86 +154,6 @@ export class ReactNativeTracing implements Integration { return eventWithView; } - /** - * Starts a new transaction for a user interaction. - * @param userInteractionId Consists of `op` representation UI Event and `elementId` unique element identifier on current screen. - */ - public startUserInteractionSpan(userInteractionId: { elementId: string | undefined; op: string }): Span | undefined { - const client = this._client; - if (!client) { - return undefined; - } - - const { elementId, op } = userInteractionId; - if (!this.options.enableUserInteractionTracing) { - logger.log('[ReactNativeTracing] User Interaction Tracing is disabled.'); - return undefined; - } - if (!this.options.routingInstrumentation) { - logger.error( - '[ReactNativeTracing] User Interaction Tracing is not working because no routing instrumentation is set.', - ); - return undefined; - } - if (!elementId) { - logger.log('[ReactNativeTracing] User Interaction Tracing can not create transaction with undefined elementId.'); - return undefined; - } - if (!this._currentRoute) { - logger.log('[ReactNativeTracing] User Interaction Tracing can not create transaction without a current route.'); - return undefined; - } - - const activeTransaction = getActiveSpan(); - const activeTransactionIsNotInteraction = - !activeTransaction || - !this._inflightInteractionTransaction || - spanToJSON(activeTransaction).span_id !== spanToJSON(this._inflightInteractionTransaction).span_id; - if (activeTransaction && activeTransactionIsNotInteraction) { - logger.warn( - `[ReactNativeTracing] Did not create ${op} transaction because active transaction ${ - spanToJSON(activeTransaction).description - } exists on the scope.`, - ); - return undefined; - } - - const name = `${this._currentRoute}.${elementId}`; - if ( - this._inflightInteractionTransaction && - spanToJSON(this._inflightInteractionTransaction).description === name && - spanToJSON(this._inflightInteractionTransaction).op === op - ) { - logger.warn( - `[ReactNativeTracing] Did not create ${op} transaction because it the same transaction ${ - spanToJSON(this._inflightInteractionTransaction).description - } already exists on the scope.`, - ); - return undefined; - } - - if (this._inflightInteractionTransaction) { - // TODO: Check the interaction transactions spec, see if can be implemented differently - // this._inflightInteractionTransaction.cancelIdleTimeout(undefined, { restartOnChildSpanChange: false }); - this._inflightInteractionTransaction = undefined; - } - - const scope = getCurrentScope(); - const context: StartSpanOptions = { - name, - op, - scope, - }; - clearActiveSpanFromScope(scope); - this._inflightInteractionTransaction = this._startIdleSpan(context); - onThisSpanEnd(client, this._inflightInteractionTransaction, () => { - this._inflightInteractionTransaction = undefined; - }); - onlySampleIfChildSpans(client, this._inflightInteractionTransaction); - logger.log(`[ReactNativeTracing] User Interaction Tracing Created ${op} transaction ${name}.`); - return this._inflightInteractionTransaction; - } - /** * Sets the current view name into the app context. * @param event Le event. @@ -291,7 +167,16 @@ export class ReactNativeTracing implements Integration { /** To be called when the route changes, but BEFORE the components of the new route mount. */ private _onRouteWillChange(): Span | undefined { - return this._createRouteTransaction(); + return startIdleNavigationSpan( + { + name: 'Route Change', + }, + { + finalTimeout: this.options.finalTimeoutMs, + idleTimeout: this.options.idleTimeoutMs, + ignoreEmptyBackNavigationTransactions: this.options.ignoreEmptyBackNavigationTransactions, + }, + ); } /** @@ -299,84 +184,20 @@ export class ReactNativeTracing implements Integration { */ private _onConfirmRoute(currentViewName: string | undefined): void { this._currentViewName = currentViewName; - this._currentRoute = currentViewName; + this.currentRoute = currentViewName; } +} - /** Create routing idle transaction. */ - private _createRouteTransaction({ - name, - op, - }: { - name?: string; - op?: string; - } = {}): Span | undefined { - if (!this._client) { - logger.warn(`[ReactNativeTracing] Can't create route change span, missing client.`); - return undefined; - } - - if (this._inflightInteractionTransaction) { - logger.log( - `[ReactNativeTracing] Canceling ${ - spanToJSON(this._inflightInteractionTransaction).op - } transaction because of a new navigation root span.`, - ); - this._inflightInteractionTransaction.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); - this._inflightInteractionTransaction.end(); - } - - const { finalTimeoutMs } = this.options; - - const expandedContext: StartSpanOptions = { - name: name || 'Route Change', - op, - forceTransaction: true, - scope: getCurrentScope(), - }; - - const idleSpan = this._startIdleSpan(expandedContext); - if (!idleSpan) { - return undefined; - } - - logger.log(`[ReactNativeTracing] Starting ${op || 'unknown op'} transaction "${name}" on scope`); - - adjustTransactionDuration(this._client, idleSpan, finalTimeoutMs); - - if (this.options.ignoreEmptyBackNavigationTransactions) { - ignoreEmptyBackNavigation(this._client, idleSpan); - } - - return idleSpan; - } - - /** - * Start app state aware idle transaction on the scope. - */ - private _startIdleSpan(startSpanOption: StartSpanOptions, beforeSpanEnd?: (span: Span) => void): Span { - if (!this._client) { - logger.warn(`[ReactNativeTracing] Can't create idle span, missing client.`); - return new SentryNonRecordingSpan(); - } - - getCurrentScope().setPropagationContext(generatePropagationContext()); - - const { idleTimeoutMs, finalTimeoutMs } = this.options; - const span = startIdleSpan(startSpanOption, { - finalTimeout: finalTimeoutMs, - idleTimeout: idleTimeoutMs, - beforeSpanEnd, - }); - cancelInBackground(this._client, span); - return span; +/** + * Returns the current React Native Tracing integration. + */ +export function getCurrentReactNativeTracingIntegration(): ReactNativeTracing | undefined { + const client = getClient(); + if (!client) { + return undefined; } -} -function generatePropagationContext(): PropagationContext { - return { - traceId: uuid4(), - spanId: uuid4().substring(16), - }; + return client.getIntegrationByName(ReactNativeTracing.id) as ReactNativeTracing | undefined; } function addDefaultOpForSpanFrom(client: Client): void { diff --git a/src/js/tracing/span.ts b/src/js/tracing/span.ts new file mode 100644 index 0000000000..c6d7b8bc83 --- /dev/null +++ b/src/js/tracing/span.ts @@ -0,0 +1,111 @@ +import { + getActiveSpan, + getClient, + getCurrentScope, + SentryNonRecordingSpan, + SPAN_STATUS_ERROR, + spanToJSON, + startIdleSpan as coreStartIdleSpan, +} from '@sentry/core'; +import type { Scope, Span, StartSpanOptions } from '@sentry/types'; +import { generatePropagationContext, logger } from '@sentry/utils'; + +import { isRootSpan } from '../utils/span'; +import { adjustTransactionDuration, cancelInBackground, ignoreEmptyBackNavigation } from './onSpanEndUtils'; +import { SPAN_ORIGIN_AUTO_INTERACTION } from './origin'; + +export const startIdleNavigationSpan = ( + { + name, + op, + }: { + name?: string; + op?: string; + } = {}, + { + finalTimeout, + idleTimeout, + ignoreEmptyBackNavigationTransactions, + }: { + finalTimeout: number; + idleTimeout: number; + ignoreEmptyBackNavigationTransactions: boolean; + }, +): Span | undefined => { + const client = getClient(); + if (!client) { + logger.warn(`[ReactNativeTracing] Can't create route change span, missing client.`); + return undefined; + } + + const activeSpan = getActiveSpan(); + if (activeSpan && isRootSpan(activeSpan) && isSentryInteractionSpan(activeSpan)) { + logger.log( + `[ReactNativeTracing] Canceling ${spanToJSON(activeSpan).op} transaction because of a new navigation root span.`, + ); + activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); + activeSpan.end(); + } + + const expandedContext: StartSpanOptions = { + name, + op, + forceTransaction: true, + scope: getCurrentScope(), + }; + + const idleSpan = startIdleSpan(expandedContext, { finalTimeout, idleTimeout }); + logger.log(`[ReactNativeTracing] Starting ${op || 'unknown op'} transaction "${name}" on scope`); + + adjustTransactionDuration(client, idleSpan, finalTimeout); + if (ignoreEmptyBackNavigationTransactions) { + ignoreEmptyBackNavigation(client, idleSpan); + } + + return idleSpan; +}; + +/** + * Starts an idle span from `@sentry/core` with React Native application + * context awareness. + * + * - Span will be started with new propagation context. + * - Span will be canceled if the app goes to background. + */ +export const startIdleSpan = ( + startSpanOption: StartSpanOptions, + { finalTimeout, idleTimeout }: { finalTimeout: number | undefined; idleTimeout: number | undefined }, +): Span => { + const client = getClient(); + if (!client) { + logger.warn(`[ReactNativeTracing] Can't create idle span, missing client.`); + return new SentryNonRecordingSpan(); + } + + getCurrentScope().setPropagationContext(generatePropagationContext()); + + const span = coreStartIdleSpan(startSpanOption, { finalTimeout, idleTimeout }); + cancelInBackground(client, span); + return span; +}; + +/** + * Checks if the span is a Sentry User Interaction span. + */ +export function isSentryInteractionSpan(span: Span): boolean { + return spanToJSON(span).origin === SPAN_ORIGIN_AUTO_INTERACTION; +} + +const SCOPE_SPAN_FIELD = '_sentrySpan'; + +type ScopeWithMaybeSpan = Scope & { + [SCOPE_SPAN_FIELD]?: Span; +}; + +/** + * Removes the active span from the scope. + */ +export function clearActiveSpanFromScope(scope: ScopeWithMaybeSpan): void { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete scope[SCOPE_SPAN_FIELD]; +} From 92e04ee37c6d5d38af35607399e3b10e4d7ea954 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 7 Aug 2024 10:30:06 +0200 Subject: [PATCH 25/37] Apply suggestions from code review Co-authored-by: LucasZF --- src/js/options.ts | 2 ++ test/sdk.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/js/options.ts b/src/js/options.ts index 731f8848ff..38d66e2ae8 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -208,6 +208,8 @@ export interface BaseReactNativeOptions { /** * Track when and how long the JS event loop stalls for. Adds stalls as measurements to all transactions. + * + * @default true */ enableStallTracking?: boolean; diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 2d90123811..5838e0be30 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -456,7 +456,7 @@ describe('Tests the SDK functionality', () => { expectNotIntegration('NativeFrames'); }); - it('no stall tracking integration by default', () => { + it('when tracing not set stall tracking the integration is not added', () => { init({}); expectNotIntegration('StallTracking'); From 1168d4e6bef3da47d3bb3ec713ae61ee30a86599 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 7 Aug 2024 15:00:23 +0200 Subject: [PATCH 26/37] Revert "fix changelog" This reverts commit 5c12e5c74c34a9b1f86d795413f32a6fdbf41176. --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ec9404c5..79a8b0e65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ ### Changes -- Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) -- Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) +- Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) +- Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) - New App Start Integration ([#3852](https://github.com/getsentry/sentry-react-native/pull/3852)) From a21e83d5e363d64067f6d6d287f08dc84e18d262 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 7 Aug 2024 15:01:18 +0200 Subject: [PATCH 27/37] Revert "misc(tracing): Remove ReactNativeTracing deprecated options" This reverts commit 4f3ca7b2ab0780d8f33cd582a503b114d63358c3. --- CHANGELOG.md | 2 -- src/js/tracing/reactnativetracing.ts | 17 +++++++-- test/tracing/reactnativetracing.test.ts | 48 +++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a8b0e65e..5c6b4dc708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,6 @@ ### Changes -- Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) -- Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) - New App Start Integration ([#3852](https://github.com/getsentry/sentry-react-native/pull/3852)) diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 492d31c686..a0bb141c06 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -58,6 +58,8 @@ const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { ...defaultRequestInstrumentationOptions, + idleTimeout: 1000, + maxTransactionDuration: 600, idleTimeoutMs: 1000, finalTimeoutMs: 600000, ignoreEmptyBackNavigationTransactions: true, @@ -98,8 +100,19 @@ export class ReactNativeTracing implements Integration { this.options = { ...defaultReactNativeTracingOptions, ...options, - finalTimeoutMs: options.finalTimeoutMs ?? defaultReactNativeTracingOptions.finalTimeoutMs, - idleTimeoutMs: options.idleTimeoutMs ?? defaultReactNativeTracingOptions.idleTimeoutMs, + finalTimeoutMs: + options.finalTimeoutMs ?? + // eslint-disable-next-line deprecation/deprecation + (typeof options.maxTransactionDuration === 'number' + ? // eslint-disable-next-line deprecation/deprecation + options.maxTransactionDuration * 1000 + : undefined) ?? + defaultReactNativeTracingOptions.finalTimeoutMs, + idleTimeoutMs: + options.idleTimeoutMs ?? + // eslint-disable-next-line deprecation/deprecation + options.idleTimeout ?? + defaultReactNativeTracingOptions.idleTimeoutMs, }; } diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 209c343cac..1d840e0f31 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -292,6 +292,54 @@ describe('ReactNativeTracing', () => { }); }); }); + describe('Handling deprecated options', () => { + test('finalTimeoutMs overrides maxTransactionDuration', () => { + const tracing = new ReactNativeTracing({ + finalTimeoutMs: 123000, + maxTransactionDuration: 456, + }); + expect(tracing.options.finalTimeoutMs).toBe(123000); + // eslint-disable-next-line deprecation/deprecation + expect(tracing.options.maxTransactionDuration).toBe(456); + }); + test('maxTransactionDuration translates to finalTimeoutMs', () => { + const tracing = new ReactNativeTracing({ + maxTransactionDuration: 123, + }); + expect(tracing.options.finalTimeoutMs).toBe(123000); + // eslint-disable-next-line deprecation/deprecation + expect(tracing.options.maxTransactionDuration).toBe(123); + }); + test('if none maxTransactionDuration and finalTimeoutMs is specified use default', () => { + const tracing = new ReactNativeTracing({}); + expect(tracing.options.finalTimeoutMs).toBe(600000); + // eslint-disable-next-line deprecation/deprecation + expect(tracing.options.maxTransactionDuration).toBe(600); + }); + test('idleTimeoutMs overrides idleTimeout', () => { + const tracing = new ReactNativeTracing({ + idleTimeoutMs: 123, + idleTimeout: 456, + }); + expect(tracing.options.idleTimeoutMs).toBe(123); + // eslint-disable-next-line deprecation/deprecation + expect(tracing.options.idleTimeout).toBe(456); + }); + test('idleTimeout translates to idleTimeoutMs', () => { + const tracing = new ReactNativeTracing({ + idleTimeout: 123, + }); + expect(tracing.options.idleTimeoutMs).toBe(123); + // eslint-disable-next-line deprecation/deprecation + expect(tracing.options.idleTimeout).toBe(123); + }); + test('if none idleTimeout and idleTimeoutMs is specified use default', () => { + const tracing = new ReactNativeTracing({}); + expect(tracing.options.idleTimeoutMs).toBe(1000); + // eslint-disable-next-line deprecation/deprecation + expect(tracing.options.idleTimeout).toBe(1000); + }); + }); describe('User Interaction Tracing', () => { let client: TestClient; From af4c453aeee2b051c86cad1a86821d7a849f6dd7 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 7 Aug 2024 15:07:37 +0200 Subject: [PATCH 28/37] tests --- .../integrations/userInteraction.test.ts | 299 ++++++++++++++++++ test/tracing/reactnativetracing.test.ts | 274 ---------------- 2 files changed, 299 insertions(+), 274 deletions(-) create mode 100644 test/tracing/integrations/userInteraction.test.ts diff --git a/test/tracing/integrations/userInteraction.test.ts b/test/tracing/integrations/userInteraction.test.ts new file mode 100644 index 0000000000..afa4ca3c80 --- /dev/null +++ b/test/tracing/integrations/userInteraction.test.ts @@ -0,0 +1,299 @@ +import { + getActiveSpan, + getCurrentScope, + SPAN_STATUS_ERROR, + spanToJSON, + startInactiveSpan, + startSpanManual, +} from '@sentry/core'; +import type { Span } from '@sentry/types'; +import type { AppState, AppStateStatus } from 'react-native'; + +import { + startUserInteractionSpan, + userInteractionIntegration, +} from '../../../src/js/tracing/integrations/userInteraction'; +import { + type ReactNativeTracingIntegration, + reactNativeTracingIntegration, +} from '../../../src/js/tracing/reactnativetracing'; +import { NATIVE } from '../../../src/js/wrapper'; +import type { TestClient } from '../../mocks/client'; +import { setupTestClient } from '../../mocks/client'; +import type { MockedRoutingInstrumentation } from '../mockedrountinginstrumention'; +import { createMockedRoutingInstrumentation } from '../mockedrountinginstrumention'; + +type MockAppState = { + setState: (state: AppStateStatus) => void; + listener: (newState: AppStateStatus) => void; + removeSubscription: jest.Func; +}; +const mockedAppState: AppState & MockAppState = { + removeSubscription: jest.fn(), + listener: jest.fn(), + isAvailable: true, + currentState: 'active', + addEventListener: (_, listener) => { + mockedAppState.listener = listener; + return { + remove: mockedAppState.removeSubscription, + }; + }, + setState: (state: AppStateStatus) => { + mockedAppState.currentState = state; + mockedAppState.listener(state); + }, +}; +jest.mock('react-native/Libraries/AppState/AppState', () => mockedAppState); + +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, + }, + }; +}); + +describe('User Interaction Tracing', () => { + let client: TestClient; + let tracing: ReactNativeTracingIntegration; + let mockedUserInteractionId: { elementId: string | undefined; op: string }; + let mockedRoutingInstrumentation: MockedRoutingInstrumentation; + + beforeEach(() => { + jest.useFakeTimers(); + NATIVE.enableNative = true; + mockedAppState.isAvailable = true; + mockedAppState.addEventListener = (_, listener) => { + mockedAppState.listener = listener; + return { + remove: mockedAppState.removeSubscription, + }; + }; + + mockedUserInteractionId = { elementId: 'mockedElementId', op: 'mocked.op' }; + client = setupTestClient({ + enableUserInteractionTracing: true, + }); + mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + describe('disabled user interaction', () => { + test('User interaction tracing is disabled by default', () => { + client = setupTestClient({}); + mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); + startUserInteractionSpan(mockedUserInteractionId); + + expect(client.getOptions().enableUserInteractionTracing).toBeFalsy(); + expect(getActiveSpan()).toBeUndefined(); + }); + }); + + describe('enabled user interaction', () => { + beforeEach(() => { + tracing = reactNativeTracingIntegration({ + routingInstrumentation: mockedRoutingInstrumentation, + }); + client.addIntegration(userInteractionIntegration()); + client.addIntegration(tracing); + mockedRoutingInstrumentation.registeredOnConfirmRoute!('mockedRouteName'); + }); + + test('user interaction tracing is enabled and transaction is bound to scope', () => { + startUserInteractionSpan(mockedUserInteractionId); + + const actualTransaction = getActiveSpan(); + const actualTransactionContext = spanToJSON(actualTransaction!); + expect(client.getOptions().enableUserInteractionTracing).toBeTruthy(); + expect(actualTransactionContext).toEqual( + expect.objectContaining({ + description: 'mockedRouteName.mockedElementId', + op: 'mocked.op', + }), + ); + }); + + test('UI event transaction not sampled if no child spans', () => { + startUserInteractionSpan(mockedUserInteractionId); + const actualTransaction = getActiveSpan(); + + jest.runAllTimers(); + + expect(actualTransaction).toBeDefined(); + expect(client.event).toBeUndefined(); + }); + + test('does cancel UI event transaction when app goes to background', () => { + startUserInteractionSpan(mockedUserInteractionId); + const actualTransaction = getActiveSpan(); + + mockedAppState.setState('background'); + jest.runAllTimers(); + + const actualTransactionContext = spanToJSON(actualTransaction!); + expect(actualTransactionContext).toEqual( + expect.objectContaining({ + timestamp: expect.any(Number), + status: 'cancelled', + }), + ); + expect(mockedAppState.removeSubscription).toBeCalledTimes(1); + }); + + test('do not overwrite existing status of UI event transactions', () => { + startUserInteractionSpan(mockedUserInteractionId); + const actualTransaction = getActiveSpan(); + + actualTransaction?.setStatus({ code: SPAN_STATUS_ERROR, message: 'mocked_status' }); + + jest.runAllTimers(); + + const actualTransactionContext = spanToJSON(actualTransaction!); + expect(actualTransactionContext).toEqual( + expect.objectContaining({ + timestamp: expect.any(Number), + status: 'mocked_status', + }), + ); + }); + + test('same UI event and same element does not reschedule idle timeout', () => { + const timeoutCloseToActualIdleTimeoutMs = 800; + startUserInteractionSpan(mockedUserInteractionId); + const actualTransaction = getActiveSpan(); + jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); + + startUserInteractionSpan(mockedUserInteractionId); + jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); + + expect(spanToJSON(actualTransaction!).timestamp).toEqual(expect.any(Number)); + }); + + test('different UI event and same element finish first and start new transaction', () => { + const timeoutCloseToActualIdleTimeoutMs = 800; + startUserInteractionSpan(mockedUserInteractionId); + const firstTransaction = getActiveSpan(); + jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); + const childFirstTransaction = startInactiveSpan({ name: 'Child Span of the first Tx', op: 'child.op' }); + + startUserInteractionSpan({ ...mockedUserInteractionId, op: 'different.op' }); + const secondTransaction = getActiveSpan(); + jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); + childFirstTransaction?.end(); + jest.runAllTimers(); + + const firstTransactionEvent = client.eventQueue[0]; + expect(firstTransaction).toBeDefined(); + expect(firstTransactionEvent).toEqual( + expect.objectContaining({ + timestamp: expect.any(Number), + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'mocked.op', + }), + }), + }), + ); + + expect(secondTransaction).toBeDefined(); + expect(spanToJSON(secondTransaction!)).toEqual( + expect.objectContaining({ + timestamp: expect.any(Number), + op: 'different.op', + }), + ); + expect(firstTransactionEvent!.timestamp).toBeGreaterThanOrEqual(spanToJSON(secondTransaction!).start_timestamp!); + }); + + test('different UI event and same element finish first transaction with last span', () => { + const timeoutCloseToActualIdleTimeoutMs = 800; + startUserInteractionSpan(mockedUserInteractionId); + const firstTransaction = getActiveSpan(); + jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); + const childFirstTransaction = startInactiveSpan({ name: 'Child Span of the first Tx', op: 'child.op' }); + + startUserInteractionSpan({ ...mockedUserInteractionId, op: 'different.op' }); + jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); + childFirstTransaction?.end(); + jest.runAllTimers(); + + const firstTransactionEvent = client.eventQueue[0]; + expect(firstTransaction).toBeDefined(); + expect(firstTransactionEvent).toEqual( + expect.objectContaining({ + timestamp: expect.any(Number), + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'mocked.op', + }), + }), + }), + ); + }); + + test('same ui event after UI event transaction finished', () => { + startUserInteractionSpan(mockedUserInteractionId); + const firstTransaction = getActiveSpan(); + jest.runAllTimers(); + + startUserInteractionSpan(mockedUserInteractionId); + const secondTransaction = getActiveSpan(); + jest.runAllTimers(); + + const firstTransactionContext = spanToJSON(firstTransaction!); + const secondTransactionContext = spanToJSON(secondTransaction!); + expect(firstTransactionContext!.timestamp).toEqual(expect.any(Number)); + expect(secondTransactionContext!.timestamp).toEqual(expect.any(Number)); + expect(firstTransactionContext!.span_id).not.toEqual(secondTransactionContext!.span_id); + }); + + test('do not start UI event transaction if active transaction on scope', () => { + const activeTransaction = startSpanManual( + { name: 'activeTransactionOnScope', scope: getCurrentScope() }, + (span: Span) => span, + ); + expect(activeTransaction).toBeDefined(); + expect(activeTransaction).toBe(getActiveSpan()); + + startUserInteractionSpan(mockedUserInteractionId); + expect(activeTransaction).toBe(getActiveSpan()); + }); + + test('UI event transaction is canceled when routing transaction starts', () => { + const timeoutCloseToActualIdleTimeoutMs = 800; + startUserInteractionSpan(mockedUserInteractionId); + const interactionTransaction = getActiveSpan(); + jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); + + const routingTransaction = mockedRoutingInstrumentation.registeredListener!({ + name: 'newMockedRouteName', + }); + jest.runAllTimers(); + + const interactionTransactionContext = spanToJSON(interactionTransaction!); + const routingTransactionContext = spanToJSON(routingTransaction!); + expect(interactionTransactionContext).toEqual( + expect.objectContaining({ + timestamp: expect.any(Number), + status: 'cancelled', + }), + ); + expect(routingTransactionContext).toEqual( + expect.objectContaining({ + timestamp: expect.any(Number), + }), + ); + expect(interactionTransactionContext!.timestamp).toBeLessThanOrEqual(routingTransactionContext!.start_timestamp!); + }); + }); +}); diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 1d840e0f31..b918ffaef5 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -292,278 +292,4 @@ describe('ReactNativeTracing', () => { }); }); }); - describe('Handling deprecated options', () => { - test('finalTimeoutMs overrides maxTransactionDuration', () => { - const tracing = new ReactNativeTracing({ - finalTimeoutMs: 123000, - maxTransactionDuration: 456, - }); - expect(tracing.options.finalTimeoutMs).toBe(123000); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.maxTransactionDuration).toBe(456); - }); - test('maxTransactionDuration translates to finalTimeoutMs', () => { - const tracing = new ReactNativeTracing({ - maxTransactionDuration: 123, - }); - expect(tracing.options.finalTimeoutMs).toBe(123000); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.maxTransactionDuration).toBe(123); - }); - test('if none maxTransactionDuration and finalTimeoutMs is specified use default', () => { - const tracing = new ReactNativeTracing({}); - expect(tracing.options.finalTimeoutMs).toBe(600000); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.maxTransactionDuration).toBe(600); - }); - test('idleTimeoutMs overrides idleTimeout', () => { - const tracing = new ReactNativeTracing({ - idleTimeoutMs: 123, - idleTimeout: 456, - }); - expect(tracing.options.idleTimeoutMs).toBe(123); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.idleTimeout).toBe(456); - }); - test('idleTimeout translates to idleTimeoutMs', () => { - const tracing = new ReactNativeTracing({ - idleTimeout: 123, - }); - expect(tracing.options.idleTimeoutMs).toBe(123); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.idleTimeout).toBe(123); - }); - test('if none idleTimeout and idleTimeoutMs is specified use default', () => { - const tracing = new ReactNativeTracing({}); - expect(tracing.options.idleTimeoutMs).toBe(1000); - // eslint-disable-next-line deprecation/deprecation - expect(tracing.options.idleTimeout).toBe(1000); - }); - }); - - describe('User Interaction Tracing', () => { - let client: TestClient; - let tracing: ReactNativeTracing; - let mockedUserInteractionId: { elementId: string | undefined; op: string }; - let mockedRoutingInstrumentation: MockedRoutingInstrumentation; - - beforeEach(() => { - mockedUserInteractionId = { elementId: 'mockedElementId', op: 'mocked.op' }; - client = setupTestClient(); - mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); - }); - - describe('disabled user interaction', () => { - test('User interaction tracing is disabled by default', () => { - tracing = new ReactNativeTracing(); - tracing.setup(client); - tracing.startUserInteractionSpan(mockedUserInteractionId); - - expect(tracing.options.enableUserInteractionTracing).toBeFalsy(); - expect(getActiveSpan()).toBeUndefined(); - }); - }); - - describe('enabled user interaction', () => { - beforeEach(() => { - tracing = new ReactNativeTracing({ - routingInstrumentation: mockedRoutingInstrumentation, - enableUserInteractionTracing: true, - }); - tracing.setup(client); - mockedRoutingInstrumentation.registeredOnConfirmRoute!('mockedRouteName'); - }); - - test('user interaction tracing is enabled and transaction is bound to scope', () => { - tracing.startUserInteractionSpan(mockedUserInteractionId); - - const actualTransaction = getActiveSpan(); - const actualTransactionContext = spanToJSON(actualTransaction!); - expect(tracing.options.enableUserInteractionTracing).toBeTruthy(); - expect(actualTransactionContext).toEqual( - expect.objectContaining({ - description: 'mockedRouteName.mockedElementId', - op: 'mocked.op', - }), - ); - }); - - test('UI event transaction not sampled if no child spans', () => { - tracing.startUserInteractionSpan(mockedUserInteractionId); - const actualTransaction = getActiveSpan(); - - jest.runAllTimers(); - - expect(actualTransaction).toBeDefined(); - expect(client.event).toBeUndefined(); - }); - - test('does cancel UI event transaction when app goes to background', () => { - tracing.startUserInteractionSpan(mockedUserInteractionId); - const actualTransaction = getActiveSpan(); - - mockedAppState.setState('background'); - jest.runAllTimers(); - - const actualTransactionContext = spanToJSON(actualTransaction!); - expect(actualTransactionContext).toEqual( - expect.objectContaining({ - timestamp: expect.any(Number), - status: 'cancelled', - }), - ); - expect(mockedAppState.removeSubscription).toBeCalledTimes(1); - }); - - test('do not overwrite existing status of UI event transactions', () => { - tracing.startUserInteractionSpan(mockedUserInteractionId); - const actualTransaction = getActiveSpan(); - - actualTransaction?.setStatus({ code: SPAN_STATUS_ERROR, message: 'mocked_status' }); - - jest.runAllTimers(); - - const actualTransactionContext = spanToJSON(actualTransaction!); - expect(actualTransactionContext).toEqual( - expect.objectContaining({ - timestamp: expect.any(Number), - status: 'mocked_status', - }), - ); - }); - - test('same UI event and same element does not reschedule idle timeout', () => { - const timeoutCloseToActualIdleTimeoutMs = 800; - tracing.startUserInteractionSpan(mockedUserInteractionId); - const actualTransaction = getActiveSpan(); - jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - - tracing.startUserInteractionSpan(mockedUserInteractionId); - jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - - expect(spanToJSON(actualTransaction!).timestamp).toEqual(expect.any(Number)); - }); - - test('different UI event and same element finish first and start new transaction', () => { - const timeoutCloseToActualIdleTimeoutMs = 800; - tracing.startUserInteractionSpan(mockedUserInteractionId); - const firstTransaction = getActiveSpan(); - jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - const childFirstTransaction = startInactiveSpan({ name: 'Child Span of the first Tx', op: 'child.op' }); - - tracing.startUserInteractionSpan({ ...mockedUserInteractionId, op: 'different.op' }); - const secondTransaction = getActiveSpan(); - jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - childFirstTransaction?.end(); - jest.runAllTimers(); - - const firstTransactionEvent = client.eventQueue[0]; - expect(firstTransaction).toBeDefined(); - expect(firstTransactionEvent).toEqual( - expect.objectContaining({ - timestamp: expect.any(Number), - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'mocked.op', - }), - }), - }), - ); - - expect(secondTransaction).toBeDefined(); - expect(spanToJSON(secondTransaction!)).toEqual( - expect.objectContaining({ - timestamp: expect.any(Number), - op: 'different.op', - }), - ); - expect(firstTransactionEvent!.timestamp).toBeGreaterThanOrEqual( - spanToJSON(secondTransaction!).start_timestamp!, - ); - }); - - test('different UI event and same element finish first transaction with last span', () => { - const timeoutCloseToActualIdleTimeoutMs = 800; - tracing.startUserInteractionSpan(mockedUserInteractionId); - const firstTransaction = getActiveSpan(); - jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - const childFirstTransaction = startInactiveSpan({ name: 'Child Span of the first Tx', op: 'child.op' }); - - tracing.startUserInteractionSpan({ ...mockedUserInteractionId, op: 'different.op' }); - jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - childFirstTransaction?.end(); - jest.runAllTimers(); - - const firstTransactionEvent = client.eventQueue[0]; - expect(firstTransaction).toBeDefined(); - expect(firstTransactionEvent).toEqual( - expect.objectContaining({ - timestamp: expect.any(Number), - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'mocked.op', - }), - }), - }), - ); - }); - - test('same ui event after UI event transaction finished', () => { - tracing.startUserInteractionSpan(mockedUserInteractionId); - const firstTransaction = getActiveSpan(); - jest.runAllTimers(); - - tracing.startUserInteractionSpan(mockedUserInteractionId); - const secondTransaction = getActiveSpan(); - jest.runAllTimers(); - - const firstTransactionContext = spanToJSON(firstTransaction!); - const secondTransactionContext = spanToJSON(secondTransaction!); - expect(firstTransactionContext!.timestamp).toEqual(expect.any(Number)); - expect(secondTransactionContext!.timestamp).toEqual(expect.any(Number)); - expect(firstTransactionContext!.span_id).not.toEqual(secondTransactionContext!.span_id); - }); - - test('do not start UI event transaction if active transaction on scope', () => { - const activeTransaction = startSpanManual( - { name: 'activeTransactionOnScope', scope: getCurrentScope() }, - (span: Span) => span, - ); - expect(activeTransaction).toBeDefined(); - expect(activeTransaction).toBe(getActiveSpan()); - - tracing.startUserInteractionSpan(mockedUserInteractionId); - expect(activeTransaction).toBe(getActiveSpan()); - }); - - test('UI event transaction is canceled when routing transaction starts', () => { - const timeoutCloseToActualIdleTimeoutMs = 800; - tracing.startUserInteractionSpan(mockedUserInteractionId); - const interactionTransaction = getActiveSpan(); - jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - - const routingTransaction = mockedRoutingInstrumentation.registeredListener!({ - name: 'newMockedRouteName', - }); - jest.runAllTimers(); - - const interactionTransactionContext = spanToJSON(interactionTransaction!); - const routingTransactionContext = spanToJSON(routingTransaction!); - expect(interactionTransactionContext).toEqual( - expect.objectContaining({ - timestamp: expect.any(Number), - status: 'cancelled', - }), - ); - expect(routingTransactionContext).toEqual( - expect.objectContaining({ - timestamp: expect.any(Number), - }), - ); - expect(interactionTransactionContext!.timestamp).toBeLessThanOrEqual( - routingTransactionContext!.start_timestamp!, - ); - }); - }); - }); }); From 3d33c02826a481e1a944774401abb354d0cce0f1 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 7 Aug 2024 15:39:41 +0200 Subject: [PATCH 29/37] fix tests --- CHANGELOG.md | 2 + src/js/client.ts | 4 -- src/js/integrations/default.ts | 4 ++ src/js/integrations/exports.ts | 1 + src/js/options.ts | 2 + .../tracing/integrations/userInteraction.ts | 11 +--- src/js/tracing/reactnativetracing.ts | 12 +++- test/client.test.ts | 66 ------------------- test/sdk.test.ts | 33 ++++++++++ test/tracing/gesturetracing.test.ts | 8 ++- .../integrations/userInteraction.test.ts | 9 +-- test/tracing/reactnativetracing.test.ts | 7 +- 12 files changed, 65 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6b4dc708..193378db55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) +- New User Interaction Tracing Integration ([#3999](https://github.com/getsentry/sentry-react-native/pull/3999)) - 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. @@ -19,6 +20,7 @@ enableAppStartTracking: true, // default true enableNativeFramesTracking: true, // default true enableStallTracking: true, // default true + enableUserInteractionTracing: true, // default false integrations: [ Sentry.appStartIntegration({ standalone: false, // default false diff --git a/src/js/client.ts b/src/js/client.ts index a917780c68..8d2d66a09b 100644 --- a/src/js/client.ts +++ b/src/js/client.ts @@ -146,10 +146,6 @@ export class ReactNativeClient extends BaseClient { if (routingName) { this.addIntegration(createIntegration(routingName)); } - const enableUserInteractionTracing = tracing?.options.enableUserInteractionTracing; - if (enableUserInteractionTracing) { - this.addIntegration(createIntegration('ReactNativeUserInteractionTracing')); - } } /** diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index 05868fa1c1..c40bf14ae4 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -33,6 +33,7 @@ import { sdkInfoIntegration, spotlightIntegration, stallTrackingIntegration, + userInteractionIntegration, viewHierarchyIntegration, } from './exports'; import { createReactNativeRewriteFrames } from './rewriteframes'; @@ -109,6 +110,9 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ if (hasTracingEnabled && options.enableStallTracking) { integrations.push(stallTrackingIntegration()); } + if (hasTracingEnabled && options.enableUserInteractionTracing) { + integrations.push(userInteractionIntegration()); + } if (hasTracingEnabled && options.enableAutoPerformanceTracing) { integrations.push(new ReactNativeTracing()); } diff --git a/src/js/integrations/exports.ts b/src/js/integrations/exports.ts index 0fc7d5a908..345be885a7 100644 --- a/src/js/integrations/exports.ts +++ b/src/js/integrations/exports.ts @@ -16,6 +16,7 @@ export { mobileReplayIntegration } from '../replay/mobilereplay'; export { appStartIntegration } from '../tracing/integrations/appStart'; export { nativeFramesIntegration } from '../tracing/integrations/nativeFrames'; export { stallTrackingIntegration } from '../tracing/integrations/stalltracking'; +export { userInteractionIntegration } from '../tracing/integrations/userInteraction'; export { breadcrumbsIntegration, diff --git a/src/js/options.ts b/src/js/options.ts index a5cded348f..4cbf778baf 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -213,6 +213,8 @@ export interface BaseReactNativeOptions { /** * Trace User Interaction events like touch and gestures. + * + * @default false */ enableUserInteractionTracing?: boolean; diff --git a/src/js/tracing/integrations/userInteraction.ts b/src/js/tracing/integrations/userInteraction.ts index 7bf47addb2..507e99b90c 100644 --- a/src/js/tracing/integrations/userInteraction.ts +++ b/src/js/tracing/integrations/userInteraction.ts @@ -1,11 +1,4 @@ -import { - getActiveSpan, - getClient, - getCurrentScope, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - spanToJSON, - startIdleSpan, -} from '@sentry/core'; +import { getActiveSpan, getClient, getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON } from '@sentry/core'; import type { Integration, Span, StartSpanOptions } from '@sentry/types'; import { logger } from '@sentry/utils'; @@ -13,7 +6,7 @@ import type { ReactNativeClientOptions } from '../../options'; import { onlySampleIfChildSpans } from '../onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_INTERACTION } from '../origin'; import { getCurrentReactNativeTracingIntegration } from '../reactnativetracing'; -import { clearActiveSpanFromScope, isSentryInteractionSpan } from '../span'; +import { clearActiveSpanFromScope, isSentryInteractionSpan, startIdleSpan } from '../span'; const INTEGRATION_NAME = 'UserInteraction'; diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index a0bb141c06..b6fec52952 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -10,6 +10,16 @@ import { startIdleNavigationSpan } from './span'; import type { BeforeNavigate } from './types'; export interface ReactNativeTracingOptions extends RequestInstrumentationOptions { + /** + * @deprecated Replaced by idleTimeoutMs + */ + idleTimeout: number; + + /** + * @deprecated Replaced by maxTransactionDurationMs + */ + maxTransactionDuration: number; + /** * The time to wait in ms until the transaction will be finished. The transaction will use the end timestamp of * the last finished span as the endtime for the transaction. @@ -89,6 +99,7 @@ export class ReactNativeTracing implements Integration { private _hasSetTracePropagationTargets: boolean; private _currentViewName: string | undefined; + private _client: Client | undefined; public constructor(options: Partial = {}) { this._hasSetTracePropagationTargets = !!( @@ -209,7 +220,6 @@ export function getCurrentReactNativeTracingIntegration(): ReactNativeTracing | if (!client) { return undefined; } - return client.getIntegrationByName(ReactNativeTracing.id) as ReactNativeTracing | undefined; } diff --git a/test/client.test.ts b/test/client.test.ts index dd14cbf76a..f14106ce33 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -631,72 +631,6 @@ describe('Tests ReactNativeClient', () => { expect(client.getIntegrationByName('MockRoutingInstrumentation')).toBeTruthy(); }); }); - - describe('user interactions tracing as integrations', () => { - test('register user interactions tracing', () => { - const client = new ReactNativeClient( - mockedOptions({ - dsn: EXAMPLE_DSN, - integrations: [ - new ReactNativeTracing({ - enableUserInteractionTracing: true, - }), - ], - }), - ); - client.init(); - - expect(client.getIntegrationByName('ReactNativeUserInteractionTracing')).toBeTruthy(); - }); - - test('register user interactions tracing - init()', () => { - const client = new ReactNativeClient( - mockedOptions({ - dsn: EXAMPLE_DSN, - integrations: [ - new ReactNativeTracing({ - enableUserInteractionTracing: true, - }), - ], - }), - ); - client.init(); - - expect(client.getIntegrationByName('ReactNativeUserInteractionTracing')).toBeTruthy(); - }); - - test('do not register user interactions tracing', () => { - const client = new ReactNativeClient( - mockedOptions({ - dsn: EXAMPLE_DSN, - integrations: [ - new ReactNativeTracing({ - enableUserInteractionTracing: false, - }), - ], - }), - ); - client.init(); - - expect(client.getIntegrationByName('ReactNativeUserInteractionTracing')).toBeUndefined(); - }); - - test('do not register user interactions tracing - init()', () => { - const client = new ReactNativeClient( - mockedOptions({ - dsn: EXAMPLE_DSN, - integrations: [ - new ReactNativeTracing({ - enableUserInteractionTracing: false, - }), - ], - }), - ); - client.init(); - - expect(client.getIntegrationByName('ReactNativeUserInteractionTracing')).toBeUndefined(); - }); - }); }); function mockedOptions(options: Partial): ReactNativeClientOptions { diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 2d90123811..1b7eac0011 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -479,6 +479,39 @@ describe('Tests the SDK functionality', () => { expectNotIntegration('StallTracking'); }); + describe('user interaction integration', () => { + test('no integration when tracing disabled', () => { + init({}); + + expectNotIntegration('UserInteraction'); + }); + test('no integration when tracing enabled', () => { + init({ + tracesSampleRate: 0.5, + }); + + expectNotIntegration('UserInteraction'); + }); + + test('no integration when tracing enabled but user interaction explicitly disabled', () => { + init({ + tracesSampleRate: 0.5, + enableUserInteractionTracing: false, + }); + + expectNotIntegration('UserInteraction'); + }); + + test('integration added when tracing enabled and user interaction enabled', () => { + init({ + tracesSampleRate: 0.5, + enableUserInteractionTracing: true, + }); + + expectIntegration('UserInteraction'); + }); + }); + it('no default integrations', () => { init({ defaultIntegrations: false, diff --git a/test/tracing/gesturetracing.test.ts b/test/tracing/gesturetracing.test.ts index e7d00a7de7..a0171613cf 100644 --- a/test/tracing/gesturetracing.test.ts +++ b/test/tracing/gesturetracing.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_BREADCRUMB_TYPE as DEFAULT_GESTURE_BREADCRUMB_TYPE, sentryTraceGesture, } from '../../src/js/tracing/gesturetracing'; +import { startUserInteractionSpan } from '../../src/js/tracing/integrations/userInteraction'; import { ReactNativeTracing } from '../../src/js/tracing/reactnativetracing'; import { type TestClient, setupTestClient } from '../mocks/client'; import type { MockedRoutingInstrumentation } from './mockedrountinginstrumention'; @@ -55,11 +56,12 @@ describe('GestureTracing', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - client = setupTestClient(); + client = setupTestClient({ + enableUserInteractionTracing: true, + }); mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); tracing = new ReactNativeTracing({ routingInstrumentation: mockedRoutingInstrumentation, - enableUserInteractionTracing: true, }); client.addIntegration(tracing); mockedRoutingInstrumentation.registeredOnConfirmRoute!('mockedScreenName'); @@ -114,7 +116,7 @@ describe('GestureTracing', () => { sentryTraceGesture('mockedGesture', mockedGesture); const mockedTouchInteractionId = { elementId: 'mockedElementId', op: 'mocked.op' }; - tracing.startUserInteractionSpan(mockedTouchInteractionId); + startUserInteractionSpan(mockedTouchInteractionId); startChildSpan(); await jest.advanceTimersByTimeAsync(timeoutCloseToActualIdleTimeoutMs); diff --git a/test/tracing/integrations/userInteraction.test.ts b/test/tracing/integrations/userInteraction.test.ts index afa4ca3c80..dfd1b44a4d 100644 --- a/test/tracing/integrations/userInteraction.test.ts +++ b/test/tracing/integrations/userInteraction.test.ts @@ -13,10 +13,7 @@ import { startUserInteractionSpan, userInteractionIntegration, } from '../../../src/js/tracing/integrations/userInteraction'; -import { - type ReactNativeTracingIntegration, - reactNativeTracingIntegration, -} from '../../../src/js/tracing/reactnativetracing'; +import { ReactNativeTracing } from '../../../src/js/tracing/reactnativetracing'; import { NATIVE } from '../../../src/js/wrapper'; import type { TestClient } from '../../mocks/client'; import { setupTestClient } from '../../mocks/client'; @@ -60,7 +57,7 @@ jest.mock('../../../src/js/wrapper', () => { describe('User Interaction Tracing', () => { let client: TestClient; - let tracing: ReactNativeTracingIntegration; + let tracing: ReactNativeTracing; let mockedUserInteractionId: { elementId: string | undefined; op: string }; let mockedRoutingInstrumentation: MockedRoutingInstrumentation; @@ -101,7 +98,7 @@ describe('User Interaction Tracing', () => { describe('enabled user interaction', () => { beforeEach(() => { - tracing = reactNativeTracingIntegration({ + tracing = new ReactNativeTracing({ routingInstrumentation: mockedRoutingInstrumentation, }); client.addIntegration(userInteractionIntegration()); diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index b918ffaef5..f15dad80dd 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -8,7 +8,7 @@ jest.mock('@sentry/utils', () => { }); import * as SentryBrowser from '@sentry/browser'; -import type { Event, Span } from '@sentry/types'; +import type { Event } from '@sentry/types'; import { RoutingInstrumentation } from '../../src/js/tracing/routingInstrumentation'; @@ -56,16 +56,13 @@ const mockedAppState: AppState & MockAppState = { }; 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 { getActiveSpan, spanToJSON } from '@sentry/browser'; import type { AppState, AppStateStatus } from 'react-native'; import { ReactNativeTracing } from '../../src/js/tracing/reactnativetracing'; import { NATIVE } from '../../src/js/wrapper'; import type { TestClient } from '../mocks/client'; import { setupTestClient } from '../mocks/client'; -import type { MockedRoutingInstrumentation } from './mockedrountinginstrumention'; -import { createMockedRoutingInstrumentation } from './mockedrountinginstrumention'; describe('ReactNativeTracing', () => { beforeEach(() => { From c71b9003a8934eca268d524732ab337325bc238d Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 7 Aug 2024 15:46:02 +0200 Subject: [PATCH 30/37] fix tests --- test/tracing/reactnativetracing.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 1d840e0f31..6db3d5922a 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -90,9 +90,9 @@ describe('ReactNativeTracing', () => { it('uses tracePropagationTargets', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); setupTestClient({ + enableStallTracking: false, integrations: [ new ReactNativeTracing({ - enableStallTracking: false, tracePropagationTargets: ['test1', 'test2'], }), ], @@ -109,7 +109,8 @@ describe('ReactNativeTracing', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); setupTestClient({ tracePropagationTargets: ['test1', 'test2'], - integrations: [new ReactNativeTracing({ enableStallTracking: false })], + enableStallTracking: false, + integrations: [new ReactNativeTracing({})], }); expect(instrumentOutgoingRequests).toBeCalledWith( @@ -122,7 +123,8 @@ describe('ReactNativeTracing', () => { it('uses defaults', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); setupTestClient({ - integrations: [new ReactNativeTracing({ enableStallTracking: false })], + enableStallTracking: false, + integrations: [new ReactNativeTracing({})], }); expect(instrumentOutgoingRequests).toBeCalledWith( @@ -136,9 +138,9 @@ describe('ReactNativeTracing', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); setupTestClient({ tracePropagationTargets: ['test1', 'test2'], + enableStallTracking: false, integrations: [ new ReactNativeTracing({ - enableStallTracking: false, tracePropagationTargets: ['test3', 'test4'], }), ], From 474ee37a5350327e9d53b6075d03a6154d175f59 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 18:00:06 +0200 Subject: [PATCH 31/37] misc(tracing): Remove ReactNativeTracing deprecated options --- CHANGELOG.md | 2 ++ src/js/tracing/reactnativetracing.ts | 17 ++--------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 193378db55..378ac8a6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Changes +- Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) +- Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) - New User Interaction Tracing Integration ([#3999](https://github.com/getsentry/sentry-react-native/pull/3999)) diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index b6fec52952..5b2ab17d3c 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -68,8 +68,6 @@ const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { ...defaultRequestInstrumentationOptions, - idleTimeout: 1000, - maxTransactionDuration: 600, idleTimeoutMs: 1000, finalTimeoutMs: 600000, ignoreEmptyBackNavigationTransactions: true, @@ -111,19 +109,8 @@ export class ReactNativeTracing implements Integration { this.options = { ...defaultReactNativeTracingOptions, ...options, - finalTimeoutMs: - options.finalTimeoutMs ?? - // eslint-disable-next-line deprecation/deprecation - (typeof options.maxTransactionDuration === 'number' - ? // eslint-disable-next-line deprecation/deprecation - options.maxTransactionDuration * 1000 - : undefined) ?? - defaultReactNativeTracingOptions.finalTimeoutMs, - idleTimeoutMs: - options.idleTimeoutMs ?? - // eslint-disable-next-line deprecation/deprecation - options.idleTimeout ?? - defaultReactNativeTracingOptions.idleTimeoutMs, + finalTimeoutMs: options.finalTimeoutMs ?? defaultReactNativeTracingOptions.finalTimeoutMs, + idleTimeoutMs: options.idleTimeoutMs ?? defaultReactNativeTracingOptions.idleTimeoutMs, }; } From 07d9f006d0a3d892551e7e80ae2201933e21d8f0 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 6 Aug 2024 18:01:38 +0200 Subject: [PATCH 32/37] fix changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 378ac8a6ee..324bf85a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ ### Changes -- Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) -- Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) +- Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) +- Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) - New User Interaction Tracing Integration ([#3999](https://github.com/getsentry/sentry-react-native/pull/3999)) From 63860627f334323bcc281f268ea390f3102bf564 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 7 Aug 2024 16:18:36 +0200 Subject: [PATCH 33/37] refactor react native tracing to new function style integration --- samples/react-native/src/App.tsx | 5 +- src/js/client.ts | 4 +- src/js/index.ts | 2 +- src/js/integrations/default.ts | 4 +- src/js/touchevents.tsx | 6 - src/js/tracing/index.ts | 3 +- src/js/tracing/integrations/appStart.ts | 9 +- .../tracing/integrations/userInteraction.ts | 4 +- src/js/tracing/reactnativetracing.ts | 273 ++++++++---------- src/js/tracing/reactnavigation.ts | 1 - src/js/tracing/span.ts | 37 +-- test/client.test.ts | 4 +- test/sdk.test.ts | 4 +- test/tracing/addTracingExtensions.test.ts | 4 +- test/tracing/gesturetracing.test.ts | 9 +- test/tracing/reactnativenavigation.test.ts | 4 +- test/tracing/reactnativetracing.test.ts | 67 +---- .../reactnavigation.stalltracking.test.ts | 4 +- test/tracing/reactnavigation.test.ts | 4 +- test/tracing/reactnavigation.ttid.test.tsx | 4 +- 20 files changed, 188 insertions(+), 264 deletions(-) diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index ece3bb0ae9..1a5dfa0440 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -66,11 +66,10 @@ Sentry.init({ }, integrations(integrations) { integrations.push( - new Sentry.ReactNativeTracing({ + Sentry.reactNativeTracingIntegration({ // The time to wait in ms until the transaction will be finished, For testing, default is 1000 ms - idleTimeout: 5000, + idleTimeoutMs: 5_000, routingInstrumentation: reactNavigationInstrumentation, - enableUserInteractionTracing: true, ignoreEmptyBackNavigationTransactions: true, }), Sentry.httpClientIntegration({ diff --git a/src/js/client.ts b/src/js/client.ts index 8d2d66a09b..b12ec9a722 100644 --- a/src/js/client.ts +++ b/src/js/client.ts @@ -19,7 +19,7 @@ import { defaultSdkInfo } from './integrations/sdkinfo'; import type { ReactNativeClientOptions } from './options'; import type { mobileReplayIntegration } from './replay/mobilereplay'; import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay'; -import type { ReactNativeTracing } from './tracing'; +import { getReactNativeTracingIntegration } from './tracing/reactnativetracing'; import { createUserFeedbackEnvelope, items } from './utils/envelope'; import { ignoreRequireCycleLogs } from './utils/ignorerequirecyclelogs'; import { mergeOutcomes } from './utils/outcome'; @@ -141,7 +141,7 @@ export class ReactNativeClient extends BaseClient { */ protected _setupIntegrations(): void { super._setupIntegrations(); - const tracing = this.getIntegrationByName('ReactNativeTracing'); + const tracing = getReactNativeTracingIntegration(this); const routingName = tracing?.options?.routingInstrumentation?.name; if (routingName) { this.addIntegration(createIntegration(routingName)); diff --git a/src/js/index.ts b/src/js/index.ts index 79ca02795d..8854f73716 100644 --- a/src/js/index.ts +++ b/src/js/index.ts @@ -58,7 +58,7 @@ export { init, wrap, nativeCrash, flush, close, captureUserFeedback, withScope } export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; export { - ReactNativeTracing, + reactNativeTracingIntegration, ReactNavigationV5Instrumentation, ReactNavigationInstrumentation, ReactNativeNavigationInstrumentation, diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index c40bf14ae4..06ad272a70 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -3,7 +3,7 @@ import type { BrowserOptions } from '@sentry/react'; import type { Integration } from '@sentry/types'; import type { ReactNativeClientOptions } from '../options'; -import { ReactNativeTracing } from '../tracing'; +import { reactNativeTracingIntegration } from '../tracing'; import { isExpoGo, notWeb } from '../utils/environment'; import { appStartIntegration, @@ -114,7 +114,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(userInteractionIntegration()); } if (hasTracingEnabled && options.enableAutoPerformanceTracing) { - integrations.push(new ReactNativeTracing()); + integrations.push(reactNativeTracingIntegration()); } if (options.enableCaptureFailedRequests) { integrations.push(httpClientIntegration()); diff --git a/src/js/touchevents.tsx b/src/js/touchevents.tsx index 45908a9054..06ab4b5523 100644 --- a/src/js/touchevents.tsx +++ b/src/js/touchevents.tsx @@ -6,7 +6,6 @@ import type { GestureResponderEvent } from 'react-native'; import { StyleSheet, View } from 'react-native'; import { createIntegration } from './integrations/factory'; -import type { ReactNativeTracing } from './tracing'; import { startUserInteractionSpan } from './tracing/integrations/userInteraction'; import { UI_ACTION_TOUCH } from './tracing/ops'; @@ -92,17 +91,12 @@ class TouchEventBoundary extends React.Component { public readonly name: string = 'TouchEventBoundary'; - private _tracingIntegration: ReactNativeTracing | null = null; - /** * Registers the TouchEventBoundary as a Sentry Integration. */ public componentDidMount(): void { const client = getClient(); client?.addIntegration?.(createIntegration(this.name)); - if (!this._tracingIntegration && client) { - this._tracingIntegration = client.getIntegrationByName('ReactNativeTracing') || null; - } } /** diff --git a/src/js/tracing/index.ts b/src/js/tracing/index.ts index bcd0ebd8e7..96858cec95 100644 --- a/src/js/tracing/index.ts +++ b/src/js/tracing/index.ts @@ -1,4 +1,5 @@ -export { ReactNativeTracing } from './reactnativetracing'; +export { reactNativeTracingIntegration } from './reactnativetracing'; +export type { ReactNativeTracingIntegration } from './reactnativetracing'; export type { RoutingInstrumentationInstance } from './routingInstrumentation'; export { RoutingInstrumentation } from './routingInstrumentation'; diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index db7bd365b2..4a5acab1d5 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -22,7 +22,7 @@ import { APP_START_WARM as APP_START_WARM_OP, UI_LOAD as UI_LOAD_OP, } from '../ops'; -import { ReactNativeTracing } from '../reactnativetracing'; +// import { getReactNativeTracingIntegration } from '../reactnativetracing'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes'; import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs } from '../utils'; @@ -108,7 +108,7 @@ export const appStartIntegration = ({ standalone?: boolean; } = {}): AppStartIntegration => { let _client: Client | undefined = undefined; - let standalone = standaloneUserOption; + const standalone = standaloneUserOption; let isEnabled = true; let appStartDataFlushed = false; @@ -123,11 +123,10 @@ export const appStartIntegration = ({ } }; - const afterAllSetup = (client: Client): void => { + 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; + // FIXME: standalone = getReactNativeTracingIntegration(client)?.options.routingInstrumentation; } }; diff --git a/src/js/tracing/integrations/userInteraction.ts b/src/js/tracing/integrations/userInteraction.ts index 507e99b90c..b187f1b306 100644 --- a/src/js/tracing/integrations/userInteraction.ts +++ b/src/js/tracing/integrations/userInteraction.ts @@ -45,7 +45,7 @@ export const startUserInteractionSpan = (userInteractionId: { logger.log(`[${INTEGRATION_NAME}] User Interaction Tracing can not create transaction with undefined elementId.`); return undefined; } - if (!tracing.currentRoute) { + if (!tracing.state.currentRoute) { logger.log(`[${INTEGRATION_NAME}] User Interaction Tracing can not create transaction without a current route.`); return undefined; } @@ -61,7 +61,7 @@ export const startUserInteractionSpan = (userInteractionId: { return undefined; } - const name = `${tracing.currentRoute}.${elementId}`; + const name = `${tracing.state.currentRoute}.${elementId}`; if ( activeTransaction && spanToJSON(activeTransaction).description === name && diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 5b2ab17d3c..39842da7d9 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -1,43 +1,51 @@ /* eslint-disable max-lines */ -import type { RequestInstrumentationOptions } from '@sentry/browser'; -import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from '@sentry/browser'; -import { getClient, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON } from '@sentry/core'; -import type { Client, Event, Integration, Span } from '@sentry/types'; +import { instrumentOutgoingRequests } from '@sentry/browser'; +import { getClient, getCurrentScope } from '@sentry/core'; +import type { Client, Event, Integration, StartSpanOptions } from '@sentry/types'; import { logger } from '@sentry/utils'; -import type { RoutingInstrumentationInstance } from '../tracing/routingInstrumentation'; -import { startIdleNavigationSpan } from './span'; -import type { BeforeNavigate } from './types'; +import type { RoutingInstrumentationInstance } from './routingInstrumentation'; +import { addDefaultOpForSpanFrom, startIdleNavigationSpan } from './span'; -export interface ReactNativeTracingOptions extends RequestInstrumentationOptions { +export const INTEGRATION_NAME = 'ReactNativeTracing'; + +export interface ReactNativeTracingOptions { /** - * @deprecated Replaced by idleTimeoutMs + * The time that has to pass without any span being created. + * If this time is exceeded, the idle span will finish. + * + * @default 1_000 (ms) */ - idleTimeout: number; + idleTimeoutMs: number; /** - * @deprecated Replaced by maxTransactionDurationMs + * The max. time an idle span may run. + * If this time is exceeded, the idle span will finish no matter what. + * + * @default 60_0000 (ms) */ - maxTransactionDuration: number; + finalTimeoutMs: number; /** - * The time to wait in ms until the transaction will be finished. The transaction will use the end timestamp of - * the last finished span as the endtime for the transaction. - * Time is in ms. + * Flag to disable patching all together for fetch requests. * - * Default: 1000 + * @default true */ - idleTimeoutMs: number; + traceFetch: boolean; /** - * The maximum duration (transaction duration + idle timeout) of a transaction - * before it will be marked as "deadline_exceeded". - * If you never want to mark a transaction set it to 0. - * Time is in ms. + * Flag to disable patching all together for xhr requests. * - * Default: 600000 + * @default true */ - finalTimeoutMs: number; + traceXHR: boolean; + + /** + * If true, Sentry will capture http timings and add them to the corresponding http spans. + * + * @default true + */ + enableHTTPTimings: boolean; /** * The routing instrumentation to be used with the tracing integration. @@ -49,171 +57,130 @@ export interface ReactNativeTracingOptions extends RequestInstrumentationOptions * Does not sample transactions that are from routes that have been seen any more and don't have any spans. * This removes a lot of the clutter as most back navigation transactions are now ignored. * - * Default: true + * @default true */ ignoreEmptyBackNavigationTransactions: boolean; /** - * beforeNavigate is called before a navigation transaction is created and allows users to modify transaction - * context data, or drop the transaction entirely (by setting `sampled = false` in the context). - * - * @param context: The context data which will be passed to `startTransaction` by default + * A callback which is called before a span for a navigation is started. + * It receives the options passed to `startSpan`, and expects to return an updated options object. + */ + beforeStartSpan?: (options: StartSpanOptions) => StartSpanOptions; + + /** + * This function will be called before creating a span for a request with the given url. + * Return false if you don't want a span for the given url. * - * @returns A (potentially) modified context object, with `sampled = false` if the transaction should be dropped. + * @default (url: string) => true */ - beforeNavigate: BeforeNavigate; + shouldCreateSpanForRequest?(this: void, url: string): boolean; } const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { - ...defaultRequestInstrumentationOptions, - idleTimeoutMs: 1000, - finalTimeoutMs: 600000, + idleTimeoutMs: 1_000, + finalTimeoutMs: 60_0000, + traceFetch: true, + traceXHR: true, + enableHTTPTimings: true, ignoreEmptyBackNavigationTransactions: true, - beforeNavigate: context => context, }; -/** - * Tracing integration for React Native. - */ -export class ReactNativeTracing implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'ReactNativeTracing'; - - /** - * @inheritDoc - */ - public name: string = ReactNativeTracing.id; - - /** ReactNativeTracing options */ - public options: ReactNativeTracingOptions; - - public useAppStartWithProfiler: boolean = false; - - public currentRoute?: string; - - private _hasSetTracePropagationTargets: boolean; - private _currentViewName: string | undefined; - private _client: Client | undefined; - - public constructor(options: Partial = {}) { - this._hasSetTracePropagationTargets = !!( - options && - // eslint-disable-next-line deprecation/deprecation - options.tracePropagationTargets - ); - - this.options = { - ...defaultReactNativeTracingOptions, - ...options, - finalTimeoutMs: options.finalTimeoutMs ?? defaultReactNativeTracingOptions.finalTimeoutMs, - idleTimeoutMs: options.idleTimeoutMs ?? defaultReactNativeTracingOptions.idleTimeoutMs, - }; - } +type ReactNativeTracingState = { + currentRoute: string | undefined; +}; - /** - * Registers routing and request instrumentation. - */ - public setup(client: Client): void { - const clientOptions = client && client.getOptions(); - - // eslint-disable-next-line @typescript-eslint/unbound-method - const { - traceFetch, - traceXHR, - // eslint-disable-next-line deprecation/deprecation - shouldCreateSpanForRequest, - // eslint-disable-next-line deprecation/deprecation - tracePropagationTargets: thisOptionsTracePropagationTargets, - routingInstrumentation, - } = this.options; - - const clientOptionsTracePropagationTargets = clientOptions && clientOptions.tracePropagationTargets; - const tracePropagationTargets = - clientOptionsTracePropagationTargets || - (this._hasSetTracePropagationTargets && thisOptionsTracePropagationTargets) || - DEFAULT_TRACE_PROPAGATION_TARGETS; - - if (routingInstrumentation) { - routingInstrumentation.registerRoutingInstrumentation( - this._onRouteWillChange.bind(this), - this.options.beforeNavigate, - this._onConfirmRoute.bind(this), +export const reactNativeTracingIntegration = ( + options: Partial = {}, +): Integration & { + options: ReactNativeTracingOptions; + state: ReactNativeTracingState; +} => { + const state: ReactNativeTracingState = { + currentRoute: undefined, + }; + + const finalOptions = { + beforeStartSpan: (options: StartSpanOptions) => options, + ...defaultReactNativeTracingOptions, + ...options, + finalTimeoutMs: options.finalTimeoutMs ?? defaultReactNativeTracingOptions.finalTimeoutMs, + idleTimeoutMs: options.idleTimeoutMs ?? defaultReactNativeTracingOptions.idleTimeoutMs, + }; + + const setup = (client: Client): void => { + if (finalOptions.routingInstrumentation) { + const idleNavigationSpanOptions = { + finalTimeout: finalOptions.finalTimeoutMs, + idleTimeout: finalOptions.idleTimeoutMs, + ignoreEmptyBackNavigationTransactions: finalOptions.ignoreEmptyBackNavigationTransactions, + }; + finalOptions.routingInstrumentation.registerRoutingInstrumentation( + () => + startIdleNavigationSpan( + finalOptions.beforeStartSpan({ + name: 'Route Change', + op: 'navigation', + forceTransaction: true, + scope: getCurrentScope(), + }), + idleNavigationSpanOptions, + ), + () => { + // no-op, replaced by beforeStartSpan, will be removed in the future + }, + (currentViewName: string | undefined) => { + state.currentRoute = currentViewName; + }, ); } else { - logger.log('[ReactNativeTracing] Not instrumenting route changes as routingInstrumentation has not been set.'); + logger.log(`[${INTEGRATION_NAME}] Not instrumenting route changes as routingInstrumentation has not been set.`); } addDefaultOpForSpanFrom(client); instrumentOutgoingRequests({ - traceFetch, - traceXHR, - shouldCreateSpanForRequest, - tracePropagationTargets, + traceFetch: finalOptions.traceFetch, + traceXHR: finalOptions.traceXHR, + shouldCreateSpanForRequest: finalOptions.shouldCreateSpanForRequest, + tracePropagationTargets: client.getOptions().tracePropagationTargets || DEFAULT_TRACE_PROPAGATION_TARGETS, }); - } + }; - /** - * @inheritdoc - */ - public processEvent(event: Event): Promise | Event { - const eventWithView = this._getCurrentViewEventProcessor(event); - return eventWithView; - } - - /** - * Sets the current view name into the app context. - * @param event Le event. - */ - private _getCurrentViewEventProcessor(event: Event): Event { - if (event.contexts && this._currentViewName) { - event.contexts.app = { view_names: [this._currentViewName], ...event.contexts.app }; + const processEvent = (event: Event): Event => { + if (event.contexts && state.currentRoute) { + event.contexts.app = { view_names: [state.currentRoute], ...event.contexts.app }; } return event; - } - - /** To be called when the route changes, but BEFORE the components of the new route mount. */ - private _onRouteWillChange(): Span | undefined { - return startIdleNavigationSpan( - { - name: 'Route Change', - }, - { - finalTimeout: this.options.finalTimeoutMs, - idleTimeout: this.options.idleTimeoutMs, - ignoreEmptyBackNavigationTransactions: this.options.ignoreEmptyBackNavigationTransactions, - }, - ); - } + }; + + return { + name: INTEGRATION_NAME, + setup, + processEvent, + options: finalOptions, + state, + }; +}; - /** - * Save the current route to set it in context during event processing. - */ - private _onConfirmRoute(currentViewName: string | undefined): void { - this._currentViewName = currentViewName; - this.currentRoute = currentViewName; - } -} +export type ReactNativeTracingIntegration = ReturnType; /** * Returns the current React Native Tracing integration. */ -export function getCurrentReactNativeTracingIntegration(): ReactNativeTracing | undefined { +export function getCurrentReactNativeTracingIntegration(): ReactNativeTracingIntegration | undefined { const client = getClient(); if (!client) { return undefined; } - return client.getIntegrationByName(ReactNativeTracing.id) as ReactNativeTracing | undefined; + + return getReactNativeTracingIntegration(client); } -function addDefaultOpForSpanFrom(client: Client): void { - client.on('spanStart', (span: Span) => { - if (!spanToJSON(span).op) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'default'); - } - }); +/** + * Returns React Native Tracing integration of given client. + */ +export function getReactNativeTracingIntegration(client: Client): ReactNativeTracingIntegration | undefined { + return client.getIntegrationByName(INTEGRATION_NAME) as ReactNativeTracingIntegration | undefined; } diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index 0b86b4e34d..b8781d6354 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -303,7 +303,6 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }); - this._beforeNavigate?.(this._latestTransaction); // Clear the timeout so the transaction does not get cancelled. this._clearStateChangeTimeout(); diff --git a/src/js/tracing/span.ts b/src/js/tracing/span.ts index c6d7b8bc83..f73707ccf5 100644 --- a/src/js/tracing/span.ts +++ b/src/js/tracing/span.ts @@ -2,12 +2,13 @@ import { getActiveSpan, getClient, getCurrentScope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, SentryNonRecordingSpan, SPAN_STATUS_ERROR, spanToJSON, startIdleSpan as coreStartIdleSpan, } from '@sentry/core'; -import type { Scope, Span, StartSpanOptions } from '@sentry/types'; +import type { Client, Scope, Span, StartSpanOptions } from '@sentry/types'; import { generatePropagationContext, logger } from '@sentry/utils'; import { isRootSpan } from '../utils/span'; @@ -15,13 +16,7 @@ import { adjustTransactionDuration, cancelInBackground, ignoreEmptyBackNavigatio import { SPAN_ORIGIN_AUTO_INTERACTION } from './origin'; export const startIdleNavigationSpan = ( - { - name, - op, - }: { - name?: string; - op?: string; - } = {}, + startSpanOption: StartSpanOptions, { finalTimeout, idleTimeout, @@ -47,15 +42,12 @@ export const startIdleNavigationSpan = ( activeSpan.end(); } - const expandedContext: StartSpanOptions = { - name, - op, - forceTransaction: true, - scope: getCurrentScope(), - }; - - const idleSpan = startIdleSpan(expandedContext, { finalTimeout, idleTimeout }); - logger.log(`[ReactNativeTracing] Starting ${op || 'unknown op'} transaction "${name}" on scope`); + const idleSpan = startIdleSpan(startSpanOption, { finalTimeout, idleTimeout }); + logger.log( + `[ReactNativeTracing] Starting ${startSpanOption.op || 'unknown op'} transaction "${ + startSpanOption.name + }" on scope`, + ); adjustTransactionDuration(client, idleSpan, finalTimeout); if (ignoreEmptyBackNavigationTransactions) { @@ -109,3 +101,14 @@ export function clearActiveSpanFromScope(scope: ScopeWithMaybeSpan): void { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete scope[SCOPE_SPAN_FIELD]; } + +/** + * Ensures that all created spans have an operation name. + */ +export function addDefaultOpForSpanFrom(client: Client): void { + client.on('spanStart', (span: Span) => { + if (!spanToJSON(span).op) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'default'); + } + }); +} diff --git a/test/client.test.ts b/test/client.test.ts index f14106ce33..a4e5d277de 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -9,7 +9,7 @@ import * as RN from 'react-native'; import { ReactNativeClient } from '../src/js/client'; import type { ReactNativeClientOptions } from '../src/js/options'; import type { RoutingInstrumentationInstance } from '../src/js/tracing'; -import { ReactNativeTracing } from '../src/js/tracing'; +import { reactNativeTracingIntegration } from '../src/js/tracing'; import { NativeTransport } from '../src/js/transports/native'; import { SDK_NAME, SDK_PACKAGE_NAME, SDK_VERSION } from '../src/js/version'; import { NATIVE } from '../src/js/wrapper'; @@ -620,7 +620,7 @@ describe('Tests ReactNativeClient', () => { mockedOptions({ dsn: EXAMPLE_DSN, integrations: [ - new ReactNativeTracing({ + reactNativeTracingIntegration({ routingInstrumentation: mockRoutingInstrumentation, }), ], diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 522aa586ab..6854f750e6 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -12,7 +12,7 @@ import type { BaseTransportOptions, ClientOptions, Integration, Scope } from '@s import { logger } from '@sentry/utils'; import { init, withScope } from '../src/js/sdk'; -import { ReactNativeTracing, ReactNavigationInstrumentation } from '../src/js/tracing'; +import { reactNativeTracingIntegration, ReactNavigationInstrumentation } from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environment'; import { NATIVE } from './mockWrapper'; @@ -32,7 +32,7 @@ describe('Tests the SDK functionality', () => { describe('enableAutoPerformanceTracing', () => { const reactNavigationInstrumentation = (): ReactNativeTracing => { const nav = new ReactNavigationInstrumentation(); - return new ReactNativeTracing({ routingInstrumentation: nav }); + return reactNativeTracingIntegration({ routingInstrumentation: nav }); }; it('Auto Performance is disabled by default', () => { diff --git a/test/tracing/addTracingExtensions.test.ts b/test/tracing/addTracingExtensions.test.ts index bdc60b5578..4d4c5384c3 100644 --- a/test/tracing/addTracingExtensions.test.ts +++ b/test/tracing/addTracingExtensions.test.ts @@ -1,6 +1,6 @@ import { getCurrentScope, spanToJSON, startSpanManual } from '@sentry/core'; -import { ReactNativeTracing } from '../../src/js'; +import { reactNativeTracingIntegration } from '../../src/js'; import { type TestClient, setupTestClient } from '../mocks/client'; describe('Tracing extensions', () => { @@ -8,7 +8,7 @@ describe('Tracing extensions', () => { beforeEach(() => { client = setupTestClient({ - integrations: [new ReactNativeTracing()], + integrations: [reactNativeTracingIntegration()], }); }); diff --git a/test/tracing/gesturetracing.test.ts b/test/tracing/gesturetracing.test.ts index a0171613cf..d0086827b2 100644 --- a/test/tracing/gesturetracing.test.ts +++ b/test/tracing/gesturetracing.test.ts @@ -8,7 +8,8 @@ import { sentryTraceGesture, } from '../../src/js/tracing/gesturetracing'; import { startUserInteractionSpan } from '../../src/js/tracing/integrations/userInteraction'; -import { ReactNativeTracing } from '../../src/js/tracing/reactnativetracing'; +import type { ReactNativeTracingIntegration } from '../../src/js/tracing/reactnativetracing'; +import { reactNativeTracingIntegration } from '../../src/js/tracing/reactnativetracing'; import { type TestClient, setupTestClient } from '../mocks/client'; import type { MockedRoutingInstrumentation } from './mockedrountinginstrumention'; import { createMockedRoutingInstrumentation } from './mockedrountinginstrumention'; @@ -37,7 +38,7 @@ describe('GestureTracing', () => { describe('gracefully fails on invalid gestures', () => { it('gesture is undefined', () => { - const gesture = undefined; + const gesture: unknown = undefined; expect(sentryTraceGesture(label, gesture)).toBeUndefined(); }); @@ -49,7 +50,7 @@ describe('GestureTracing', () => { describe('traces gestures', () => { let client: TestClient; - let tracing: ReactNativeTracing; + let tracing: ReactNativeTracingIntegration; let mockedRoutingInstrumentation: MockedRoutingInstrumentation; let mockedGesture: MockGesture; @@ -60,7 +61,7 @@ describe('GestureTracing', () => { enableUserInteractionTracing: true, }); mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); - tracing = new ReactNativeTracing({ + tracing = reactNativeTracingIntegration({ routingInstrumentation: mockedRoutingInstrumentation, }); client.addIntegration(tracing); diff --git a/test/tracing/reactnativenavigation.test.ts b/test/tracing/reactnativenavigation.test.ts index 44c3a1abae..ff3f7ca2a7 100644 --- a/test/tracing/reactnativenavigation.test.ts +++ b/test/tracing/reactnativenavigation.test.ts @@ -10,7 +10,7 @@ import { import type { Event } from '@sentry/types'; import type { EmitterSubscription } from 'react-native'; -import { ReactNativeTracing } from '../../src/js'; +import { reactNativeTracingIntegration } from '../../src/js'; import type { BottomTabPressedEvent, ComponentWillAppearEvent, @@ -368,7 +368,7 @@ describe('React Native Navigation Instrumentation', () => { }, ); - const rnTracing = new ReactNativeTracing({ + const rnTracing = reactNativeTracingIntegration({ routingInstrumentation: rNavigation, enableStallTracking: false, enableNativeFramesTracking: false, diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index b0d584ca48..916489b1cc 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -59,7 +59,7 @@ jest.mock('react-native/Libraries/AppState/AppState', () => mockedAppState); import { getActiveSpan, spanToJSON } from '@sentry/browser'; import type { AppState, AppStateStatus } from 'react-native'; -import { ReactNativeTracing } from '../../src/js/tracing/reactnativetracing'; +import { reactNativeTracingIntegration } from '../../src/js/tracing/reactnativetracing'; import { NATIVE } from '../../src/js/wrapper'; import type { TestClient } from '../mocks/client'; import { setupTestClient } from '../mocks/client'; @@ -84,30 +84,12 @@ describe('ReactNativeTracing', () => { }); describe('trace propagation targets', () => { - it('uses tracePropagationTargets', () => { - const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); - setupTestClient({ - enableStallTracking: false, - integrations: [ - new ReactNativeTracing({ - tracePropagationTargets: ['test1', 'test2'], - }), - ], - }); - - expect(instrumentOutgoingRequests).toBeCalledWith( - expect.objectContaining({ - tracePropagationTargets: ['test1', 'test2'], - }), - ); - }); - it('uses tracePropagationTargets from client options', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); setupTestClient({ tracePropagationTargets: ['test1', 'test2'], enableStallTracking: false, - integrations: [new ReactNativeTracing({})], + integrations: [reactNativeTracingIntegration()], }); expect(instrumentOutgoingRequests).toBeCalledWith( @@ -121,7 +103,7 @@ describe('ReactNativeTracing', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); setupTestClient({ enableStallTracking: false, - integrations: [new ReactNativeTracing({})], + integrations: [reactNativeTracingIntegration()], }); expect(instrumentOutgoingRequests).toBeCalledWith( @@ -130,25 +112,6 @@ describe('ReactNativeTracing', () => { }), ); }); - - it('client tracePropagationTargets takes priority over integration options', () => { - const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); - setupTestClient({ - tracePropagationTargets: ['test1', 'test2'], - enableStallTracking: false, - integrations: [ - new ReactNativeTracing({ - tracePropagationTargets: ['test3', 'test4'], - }), - ], - }); - - expect(instrumentOutgoingRequests).toBeCalledWith( - expect.objectContaining({ - tracePropagationTargets: ['test1', 'test2'], - }), - ); - }); }); describe('Tracing Instrumentation', () => { @@ -161,7 +124,7 @@ describe('ReactNativeTracing', () => { describe('With routing instrumentation', () => { it('Cancels route transaction when app goes to background', async () => { const routingInstrumentation = new RoutingInstrumentation(); - const integration = new ReactNativeTracing({ + const integration = reactNativeTracingIntegration({ routingInstrumentation, }); @@ -191,7 +154,7 @@ describe('ReactNativeTracing', () => { const routingInstrumentation = new RoutingInstrumentation(); setupTestClient({ integrations: [ - new ReactNativeTracing({ + reactNativeTracingIntegration({ routingInstrumentation, }), ], @@ -221,7 +184,7 @@ describe('ReactNativeTracing', () => { describe('_onConfirmRoute', () => { it('Sets app context', async () => { const routing = new RoutingInstrumentation(); - const integration = new ReactNativeTracing({ + const integration = reactNativeTracingIntegration({ routingInstrumentation: routing, }); @@ -243,7 +206,7 @@ describe('ReactNativeTracing', () => { describe('View Names event processor', () => { it('Do not overwrite event app context', () => { const routing = new RoutingInstrumentation(); - const integration = new ReactNativeTracing({ + const integration = reactNativeTracingIntegration({ routingInstrumentation: routing, }); @@ -251,16 +214,15 @@ describe('ReactNativeTracing', () => { const event: Event = { contexts: { app: { appKey: 'value' } } }; const expectedEvent: Event = { contexts: { app: { appKey: 'value', view_names: [expectedRouteName] } } }; - // @ts-expect-error only for testing. - integration._currentViewName = expectedRouteName; - const processedEvent = integration['_getCurrentViewEventProcessor'](event); + integration.state.currentRoute = expectedRouteName; + const processedEvent = integration.processEvent(event, {}, client); expect(processedEvent).toEqual(expectedEvent); }); it('Do not add view_names if context is undefined', () => { const routing = new RoutingInstrumentation(); - const integration = new ReactNativeTracing({ + const integration = reactNativeTracingIntegration({ routingInstrumentation: routing, }); @@ -268,23 +230,22 @@ describe('ReactNativeTracing', () => { const event: Event = { release: 'value' }; const expectedEvent: Event = { release: 'value' }; - // @ts-expect-error only for testing. - integration._currentViewName = expectedRouteName; - const processedEvent = integration['_getCurrentViewEventProcessor'](event); + integration.state.currentRoute = expectedRouteName; + const processedEvent = integration.processEvent(event, {}, client); expect(processedEvent).toEqual(expectedEvent); }); it('ignore view_names if undefined', () => { const routing = new RoutingInstrumentation(); - const integration = new ReactNativeTracing({ + const integration = reactNativeTracingIntegration({ routingInstrumentation: routing, }); const event: Event = { contexts: { app: { key: 'value ' } } }; const expectedEvent: Event = { contexts: { app: { key: 'value ' } } }; - const processedEvent = integration['_getCurrentViewEventProcessor'](event); + const processedEvent = integration.processEvent(event, {}, client); expect(processedEvent).toEqual(expectedEvent); }); diff --git a/test/tracing/reactnavigation.stalltracking.test.ts b/test/tracing/reactnavigation.stalltracking.test.ts index 2c01838016..b3548a98ab 100644 --- a/test/tracing/reactnavigation.stalltracking.test.ts +++ b/test/tracing/reactnavigation.stalltracking.test.ts @@ -5,7 +5,7 @@ jest.mock('../../src/js/tracing/utils', () => ({ import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, startSpanManual } from '@sentry/core'; -import { ReactNativeTracing, ReactNavigationInstrumentation } from '../../src/js'; +import { reactNativeTracingIntegration, ReactNavigationInstrumentation } from '../../src/js'; import { stallTrackingIntegration } from '../../src/js/tracing/integrations/stalltracking'; import { isNearToNow } from '../../src/js/tracing/utils'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; @@ -29,7 +29,7 @@ describe('StallTracking with ReactNavigation', () => { const rnavigation = new ReactNavigationInstrumentation(); mockNavigation = createMockNavigationAndAttachTo(rnavigation); - const rnTracing = new ReactNativeTracing({ + const rnTracing = reactNativeTracingIntegration({ routingInstrumentation: rnavigation, }); diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index 1ba51d5d3f..88403ba9dd 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getCurrentScope, getGlobalScope, getIsolationScope, SentrySpan, setCurrentClient } from '@sentry/core'; -import { ReactNativeTracing } from '../../src/js'; +import { reactNativeTracingIntegration } from '../../src/js'; import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; import { ReactNavigationInstrumentation } from '../../src/js/tracing/reactnavigation'; import { @@ -331,7 +331,7 @@ describe('ReactNavigationInstrumentation', () => { }); mockNavigation = createMockNavigationAndAttachTo(rNavigation); - const rnTracing = new ReactNativeTracing({ + const rnTracing = reactNativeTracingIntegration({ routingInstrumentation: rNavigation, enableStallTracking: false, enableNativeFramesTracking: false, diff --git a/test/tracing/reactnavigation.ttid.test.tsx b/test/tracing/reactnavigation.ttid.test.tsx index f82996c6e0..e6743fc7d5 100644 --- a/test/tracing/reactnavigation.ttid.test.tsx +++ b/test/tracing/reactnavigation.ttid.test.tsx @@ -531,10 +531,10 @@ function initSentry(sut: ReactNavigationInstrumentation): { const options: Sentry.ReactNativeOptions = { dsn: MOCK_DSN, enableTracing: true, + enableStallTracking: false, integrations: [ - new Sentry.ReactNativeTracing({ + Sentry.reactNativeTracingIntegration({ routingInstrumentation: sut, - enableStallTracking: false, ignoreEmptyBackNavigationTransactions: true, // default true }), ], From 30023938be6c576e66b6cbbb49ec745022aff8ec Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 7 Aug 2024 16:47:02 +0200 Subject: [PATCH 34/37] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 324bf85a2f..ef7889b79b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) +- Removed `beforeNavigate` use `beforeStartSpan` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) - New User Interaction Tracing Integration ([#3999](https://github.com/getsentry/sentry-react-native/pull/3999)) From 2bbb93acc125553b64dacd6b7bbeb5356b8186d2 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Thu, 8 Aug 2024 11:26:37 +0200 Subject: [PATCH 35/37] fix test, changelog and samples --- CHANGELOG.md | 7 +++++++ samples/expo/app/_layout.tsx | 2 +- src/js/tracing/index.ts | 5 ++++- src/js/tracing/integrations/appStart.ts | 7 ++++--- src/js/tracing/reactnativenavigation.ts | 14 ++++++++++--- src/js/tracing/reactnativetracing.ts | 8 +++++--- src/js/tracing/reactnavigation.ts | 7 +++++-- test/sdk.test.ts | 19 +++++++++++++----- .../integrations/userInteraction.test.ts | 7 ++++--- test/tracing/reactnativenavigation.test.ts | 18 ++++++++--------- test/tracing/reactnavigation.test.ts | 20 ++++++++++--------- 11 files changed, 75 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef7889b79b..006b38690c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Removed deprecated ReactNativeTracing option `idleTimeout` use `idleTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - Removed deprecated ReactNativeTracing option `maxTransactionDuration` use `finalTimeoutMs` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) - Removed `beforeNavigate` use `beforeStartSpan` instead ([#3998](https://github.com/getsentry/sentry-react-native/pull/3998)) + - `beforeStartSpan` is executed before the span start, compared to `beforeNavigate` which was executed before the navigation ended (after the span was created) - New Native Frames Integration ([#3996](https://github.com/getsentry/sentry-react-native/pull/3996)) - New Stall Tracking Integration ([#3997](https://github.com/getsentry/sentry-react-native/pull/3997)) - New User Interaction Tracing Integration ([#3999](https://github.com/getsentry/sentry-react-native/pull/3999)) @@ -25,6 +26,12 @@ enableStallTracking: true, // default true enableUserInteractionTracing: true, // default false integrations: [ + Sentry.reactNativeTracingIntegration({ + beforeStartSpan: (startSpanOptions) => { + startSpanOptions.name = 'New Name'; + return startSpanOptions; + }, + }), Sentry.appStartIntegration({ standalone: false, // default false }), diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index 126a499b66..539dbb0fad 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -54,7 +54,7 @@ process.env.EXPO_SKIP_DURING_EXPORT !== 'true' && Sentry.init({ // default: [/.*/] failedRequestTargets: [/.*/], }), - new Sentry.ReactNativeTracing({ + Sentry.reactNativeTracingIntegration({ routingInstrumentation, }), ); diff --git a/src/js/tracing/index.ts b/src/js/tracing/index.ts index 96858cec95..dc071fe236 100644 --- a/src/js/tracing/index.ts +++ b/src/js/tracing/index.ts @@ -1,4 +1,7 @@ -export { reactNativeTracingIntegration } from './reactnativetracing'; +export { + reactNativeTracingIntegration, + INTEGRATION_NAME as REACT_NATIVE_TRACING_INTEGRATION_NAME, +} from './reactnativetracing'; export type { ReactNativeTracingIntegration } from './reactnativetracing'; export type { RoutingInstrumentationInstance } from './routingInstrumentation'; diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index 4a5acab1d5..809d16aff9 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -22,6 +22,7 @@ import { APP_START_WARM as APP_START_WARM_OP, UI_LOAD as UI_LOAD_OP, } from '../ops'; +import { getReactNativeTracingIntegration } from '../reactnativetracing'; // import { getReactNativeTracingIntegration } from '../reactnativetracing'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes'; import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs } from '../utils'; @@ -108,7 +109,7 @@ export const appStartIntegration = ({ standalone?: boolean; } = {}): AppStartIntegration => { let _client: Client | undefined = undefined; - const standalone = standaloneUserOption; + let standalone = standaloneUserOption; let isEnabled = true; let appStartDataFlushed = false; @@ -123,10 +124,10 @@ export const appStartIntegration = ({ } }; - const afterAllSetup = (_client: Client): void => { + const afterAllSetup = (client: Client): void => { if (standaloneUserOption === undefined) { // If not user defined, set based on the routing instrumentation presence - // FIXME: standalone = getReactNativeTracingIntegration(client)?.options.routingInstrumentation; + standalone = !getReactNativeTracingIntegration(client)?.options.routingInstrumentation; } }; diff --git a/src/js/tracing/reactnativenavigation.ts b/src/js/tracing/reactnativenavigation.ts index 39d1dddf79..b07d6caff2 100644 --- a/src/js/tracing/reactnativenavigation.ts +++ b/src/js/tracing/reactnativenavigation.ts @@ -1,8 +1,14 @@ -import { addBreadcrumb, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + addBreadcrumb, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanToJSON, +} from '@sentry/core'; import type { Span } from '@sentry/types'; import type { EmitterSubscription } from '../utils/rnlibrariesinterface'; import { isSentrySpan } from '../utils/span'; +import { DEFAULT_NAVIGATION_SPAN_NAME } from './reactnativetracing'; import type { OnConfirmRoute, TransactionCreator } from './routingInstrumentation'; import { InternalRoutingInstrumentation } from './routingInstrumentation'; import type { BeforeNavigate } from './types'; @@ -124,7 +130,7 @@ export class ReactNativeNavigationInstrumentation extends InternalRoutingInstrum this._discardLatestTransaction(); } - this._latestTransaction = this.onRouteWillChange({ name: 'Route Change' }); + this._latestTransaction = this.onRouteWillChange({ name: DEFAULT_NAVIGATION_SPAN_NAME }); this._stateChangeTimeout = setTimeout( this._discardLatestTransaction.bind(this), @@ -151,7 +157,9 @@ export class ReactNativeNavigationInstrumentation extends InternalRoutingInstrum const routeHasBeenSeen = this._recentComponentIds.includes(event.componentId); - this._latestTransaction.updateName(event.componentName); + if (spanToJSON(this._latestTransaction).description === DEFAULT_NAVIGATION_SPAN_NAME) { + this._latestTransaction.updateName(event.componentName); + } this._latestTransaction.setAttributes({ // TODO: Should we include pass props? I don't know exactly what it contains, cant find it in the RNavigation docs 'route.name': event.componentName, diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 39842da7d9..d9e098d682 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -77,6 +77,7 @@ export interface ReactNativeTracingOptions { } const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; +export const DEFAULT_NAVIGATION_SPAN_NAME = 'Route Change'; const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { idleTimeoutMs: 1_000, @@ -102,9 +103,9 @@ export const reactNativeTracingIntegration = ( }; const finalOptions = { - beforeStartSpan: (options: StartSpanOptions) => options, ...defaultReactNativeTracingOptions, ...options, + beforeStartSpan: options.beforeStartSpan ?? ((options: StartSpanOptions) => options), finalTimeoutMs: options.finalTimeoutMs ?? defaultReactNativeTracingOptions.finalTimeoutMs, idleTimeoutMs: options.idleTimeoutMs ?? defaultReactNativeTracingOptions.idleTimeoutMs, }; @@ -117,13 +118,14 @@ export const reactNativeTracingIntegration = ( ignoreEmptyBackNavigationTransactions: finalOptions.ignoreEmptyBackNavigationTransactions, }; finalOptions.routingInstrumentation.registerRoutingInstrumentation( - () => + navigationInstrumentationOptions => startIdleNavigationSpan( finalOptions.beforeStartSpan({ - name: 'Route Change', + name: DEFAULT_NAVIGATION_SPAN_NAME, op: 'navigation', forceTransaction: true, scope: getCurrentScope(), + ...navigationInstrumentationOptions, }), idleNavigationSpanOptions, ), diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index b8781d6354..965f7980d0 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -16,6 +16,7 @@ import { type SentryEventEmitter, createSentryEventEmitter, NewFrameEventName } import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { NATIVE } from '../wrapper'; +import { DEFAULT_NAVIGATION_SPAN_NAME } from './reactnativetracing'; import type { OnConfirmRoute, TransactionCreator } from './routingInstrumentation'; import { InternalRoutingInstrumentation } from './routingInstrumentation'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; @@ -192,7 +193,7 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati this._clearStateChangeTimeout(); } - this._latestTransaction = this.onRouteWillChange({ name: 'Route Change' }); + this._latestTransaction = this.onRouteWillChange({ name: DEFAULT_NAVIGATION_SPAN_NAME }); if (this._options.enableTimeToInitialDisplay) { this._navigationProcessingSpan = startInactiveSpan({ @@ -288,7 +289,9 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati this._navigationProcessingSpan?.end(stateChangedTimestamp); this._navigationProcessingSpan = undefined; - this._latestTransaction.updateName(route.name); + if (spanToJSON(this._latestTransaction).description === DEFAULT_NAVIGATION_SPAN_NAME) { + this._latestTransaction.updateName(route.name); + } this._latestTransaction.setAttributes({ 'route.name': route.name, 'route.key': route.key, diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 6854f750e6..1c56a9d465 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -12,7 +12,12 @@ import type { BaseTransportOptions, ClientOptions, Integration, Scope } from '@s import { logger } from '@sentry/utils'; import { init, withScope } from '../src/js/sdk'; -import { reactNativeTracingIntegration, ReactNavigationInstrumentation } from '../src/js/tracing'; +import type { ReactNativeTracingIntegration } from '../src/js/tracing'; +import { + REACT_NATIVE_TRACING_INTEGRATION_NAME, + reactNativeTracingIntegration, + ReactNavigationInstrumentation, +} from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environment'; import { NATIVE } from './mockWrapper'; @@ -30,7 +35,7 @@ describe('Tests the SDK functionality', () => { describe('init', () => { describe('enableAutoPerformanceTracing', () => { - const reactNavigationInstrumentation = (): ReactNativeTracing => { + const reactNavigationInstrumentation = (): ReactNativeTracingIntegration => { const nav = new ReactNavigationInstrumentation(); return reactNativeTracingIntegration({ routingInstrumentation: nav }); }; @@ -84,7 +89,9 @@ describe('Tests the SDK functionality', () => { }); const options = usedIntegrations(); - expect(options.filter(integration => integration.name === ReactNativeTracing.id).length).toBe(1); + expect(options.filter(integration => integration.name === REACT_NATIVE_TRACING_INTEGRATION_NAME).length).toBe( + 1, + ); expect(options.some(integration => integration === tracing)).toBe(true); }); @@ -97,7 +104,9 @@ describe('Tests the SDK functionality', () => { }); const options = usedIntegrations(); - expect(options.filter(integration => integration.name === ReactNativeTracing.id).length).toBe(1); + expect(options.filter(integration => integration.name === REACT_NATIVE_TRACING_INTEGRATION_NAME).length).toBe( + 1, + ); expect(options.some(integration => integration === tracing)).toBe(true); }); }); @@ -681,5 +690,5 @@ function usedIntegrations(): Integration[] { } function autoPerformanceIsEnabled(): boolean { - return usedIntegrations().some(integration => integration.name === ReactNativeTracing.id); + return usedIntegrations().some(integration => integration.name === REACT_NATIVE_TRACING_INTEGRATION_NAME); } diff --git a/test/tracing/integrations/userInteraction.test.ts b/test/tracing/integrations/userInteraction.test.ts index dfd1b44a4d..01bcd86a0c 100644 --- a/test/tracing/integrations/userInteraction.test.ts +++ b/test/tracing/integrations/userInteraction.test.ts @@ -13,7 +13,8 @@ import { startUserInteractionSpan, userInteractionIntegration, } from '../../../src/js/tracing/integrations/userInteraction'; -import { ReactNativeTracing } from '../../../src/js/tracing/reactnativetracing'; +import type { ReactNativeTracingIntegration } from '../../../src/js/tracing/reactnativetracing'; +import { reactNativeTracingIntegration } from '../../../src/js/tracing/reactnativetracing'; import { NATIVE } from '../../../src/js/wrapper'; import type { TestClient } from '../../mocks/client'; import { setupTestClient } from '../../mocks/client'; @@ -57,7 +58,7 @@ jest.mock('../../../src/js/wrapper', () => { describe('User Interaction Tracing', () => { let client: TestClient; - let tracing: ReactNativeTracing; + let tracing: ReactNativeTracingIntegration; let mockedUserInteractionId: { elementId: string | undefined; op: string }; let mockedRoutingInstrumentation: MockedRoutingInstrumentation; @@ -98,7 +99,7 @@ describe('User Interaction Tracing', () => { describe('enabled user interaction', () => { beforeEach(() => { - tracing = new ReactNativeTracing({ + tracing = reactNativeTracingIntegration({ routingInstrumentation: mockedRoutingInstrumentation, }); client.addIntegration(userInteractionIntegration()); diff --git a/test/tracing/reactnativenavigation.test.ts b/test/tracing/reactnativenavigation.test.ts index ff3f7ca2a7..f07c7c5505 100644 --- a/test/tracing/reactnativenavigation.test.ts +++ b/test/tracing/reactnativenavigation.test.ts @@ -7,7 +7,7 @@ import { setCurrentClient, spanToJSON, } from '@sentry/core'; -import type { Event } from '@sentry/types'; +import type { Event, StartSpanOptions } from '@sentry/types'; import type { EmitterSubscription } from 'react-native'; import { reactNativeTracingIntegration } from '../../src/js'; @@ -31,7 +31,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../../src/js/tracing/semanticAttributes'; -import type { BeforeNavigate } from '../../src/js/tracing/types'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; interface MockEventsRegistry extends EventsRegistry { @@ -94,10 +93,11 @@ describe('React Native Navigation Instrumentation', () => { ); }); - test('Transaction context is changed with beforeNavigate', async () => { + test('start span options are changes by before start span callback', async () => { setupTestClient({ - beforeNavigate: span => { - span.updateName('New Name'); + beforeStartSpan: startSpanOptions => { + startSpanOptions.name = 'New Name'; + return startSpanOptions; }, }); @@ -351,7 +351,7 @@ describe('React Native Navigation Instrumentation', () => { function setupTestClient( setupOptions: { - beforeNavigate?: BeforeNavigate; + beforeStartSpan?: (options: StartSpanOptions) => StartSpanOptions; enableTabsInstrumentation?: boolean; } = {}, ) { @@ -370,13 +370,13 @@ describe('React Native Navigation Instrumentation', () => { const rnTracing = reactNativeTracingIntegration({ routingInstrumentation: rNavigation, - enableStallTracking: false, - enableNativeFramesTracking: false, - beforeNavigate: setupOptions.beforeNavigate, + beforeStartSpan: setupOptions.beforeStartSpan, }); const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, + enableStallTracking: false, + enableNativeFramesTracking: false, integrations: [rnTracing], enableAppStartTracking: false, }); diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index 88403ba9dd..55f76b40ac 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -1,8 +1,10 @@ /* eslint-disable deprecation/deprecation */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getCurrentScope, getGlobalScope, getIsolationScope, SentrySpan, setCurrentClient } from '@sentry/core'; +import type { StartSpanOptions } from '@sentry/types'; import { reactNativeTracingIntegration } from '../../src/js'; +import { DEFAULT_NAVIGATION_SPAN_NAME } from '../../src/js/tracing/reactnativetracing'; import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; import { ReactNavigationInstrumentation } from '../../src/js/tracing/reactnavigation'; import { @@ -17,7 +19,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../../src/js/tracing/semanticAttributes'; -import type { BeforeNavigate } from '../../src/js/tracing/types'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { createMockNavigationAndAttachTo } from './reactnavigationutils'; @@ -152,10 +153,11 @@ describe('ReactNavigationInstrumentation', () => { ); }); - test('transaction context changed with beforeNavigate', async () => { + test('start span option changed in before start span callback', async () => { setupTestClient({ - beforeNavigate: span => { - span.updateName('New Span Name'); + beforeSpanStart: startSpanOption => { + startSpanOption.name = 'New Span Name'; + return startSpanOption; }, }); jest.runOnlyPendingTimers(); // Flush the init transaction @@ -295,7 +297,7 @@ describe('ReactNavigationInstrumentation', () => { routeChangeTimeoutMs: 200, }); - const mockTransaction = new SentrySpan({ sampled: true }); + const mockTransaction = new SentrySpan({ sampled: true, name: DEFAULT_NAVIGATION_SPAN_NAME }); const tracingListener = jest.fn(() => mockTransaction); instrumentation.registerRoutingInstrumentation( tracingListener as any, @@ -323,7 +325,7 @@ describe('ReactNavigationInstrumentation', () => { function setupTestClient( setupOptions: { - beforeNavigate?: BeforeNavigate; + beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions; } = {}, ) { const rNavigation = new ReactNavigationInstrumentation({ @@ -333,12 +335,12 @@ describe('ReactNavigationInstrumentation', () => { const rnTracing = reactNativeTracingIntegration({ routingInstrumentation: rNavigation, - enableStallTracking: false, - enableNativeFramesTracking: false, - beforeNavigate: setupOptions.beforeNavigate, + beforeStartSpan: setupOptions.beforeSpanStart, }); const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, tracesSampleRate: 1.0, integrations: [rnTracing], enableAppStartTracking: false, From 9dd899ee81b67be7480531925311a0d685e41989 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Thu, 8 Aug 2024 13:38:35 +0200 Subject: [PATCH 36/37] fix(tracing): ReactNativeTracing and initial navigation spans have to be created after integrations setup --- src/js/tracing/integrations/nativeFrames.ts | 1 - src/js/tracing/reactnativetracing.ts | 21 ++--- test/tracing/reactnavigation.test.ts | 85 ++++++++++++++++++++- 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/js/tracing/integrations/nativeFrames.ts b/src/js/tracing/integrations/nativeFrames.ts index 1c00ffddd1..012e2d5349 100644 --- a/src/js/tracing/integrations/nativeFrames.ts +++ b/src/js/tracing/integrations/nativeFrames.ts @@ -73,7 +73,6 @@ export const nativeFramesIntegration = (): Integration => { NATIVE.enableNativeFramesTracking(); - // TODO: Ensure other integrations like ReactNativeTracing and ReactNavigation create spans after all integration are setup. client.on('spanStart', _onSpanStart); client.on('spanEnd', _onSpanFinish); logger.log('[ReactNativeTracing] Native frames instrumentation initialized.'); diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index d9e098d682..d4397c2a77 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -111,6 +111,17 @@ export const reactNativeTracingIntegration = ( }; const setup = (client: Client): void => { + addDefaultOpForSpanFrom(client); + + instrumentOutgoingRequests({ + traceFetch: finalOptions.traceFetch, + traceXHR: finalOptions.traceXHR, + shouldCreateSpanForRequest: finalOptions.shouldCreateSpanForRequest, + tracePropagationTargets: client.getOptions().tracePropagationTargets || DEFAULT_TRACE_PROPAGATION_TARGETS, + }); + }; + + const afterAllSetup = (): void => { if (finalOptions.routingInstrumentation) { const idleNavigationSpanOptions = { finalTimeout: finalOptions.finalTimeoutMs, @@ -139,15 +150,6 @@ export const reactNativeTracingIntegration = ( } else { logger.log(`[${INTEGRATION_NAME}] Not instrumenting route changes as routingInstrumentation has not been set.`); } - - addDefaultOpForSpanFrom(client); - - instrumentOutgoingRequests({ - traceFetch: finalOptions.traceFetch, - traceXHR: finalOptions.traceXHR, - shouldCreateSpanForRequest: finalOptions.shouldCreateSpanForRequest, - tracePropagationTargets: client.getOptions().tracePropagationTargets || DEFAULT_TRACE_PROPAGATION_TARGETS, - }); }; const processEvent = (event: Event): Event => { @@ -160,6 +162,7 @@ export const reactNativeTracingIntegration = ( return { name: INTEGRATION_NAME, setup, + afterAllSetup, processEvent, options: finalOptions, state, diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index 55f76b40ac..9806aa891f 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -1,9 +1,9 @@ /* eslint-disable deprecation/deprecation */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getCurrentScope, getGlobalScope, getIsolationScope, SentrySpan, setCurrentClient } from '@sentry/core'; -import type { StartSpanOptions } from '@sentry/types'; +import type { Event, Measurements, StartSpanOptions } from '@sentry/types'; -import { reactNativeTracingIntegration } from '../../src/js'; +import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js'; import { DEFAULT_NAVIGATION_SPAN_NAME } from '../../src/js/tracing/reactnativetracing'; import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; import { ReactNavigationInstrumentation } from '../../src/js/tracing/reactnavigation'; @@ -21,6 +21,7 @@ import { } from '../../src/js/tracing/semanticAttributes'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; +import { NATIVE } from '../mockWrapper'; import { createMockNavigationAndAttachTo } from './reactnavigationutils'; const dummyRoute = { @@ -28,6 +29,7 @@ const dummyRoute = { key: '0', }; +jest.mock('../../src/js/wrapper.ts', () => jest.requireActual('../mockWrapper.ts')); jest.useFakeTimers({ advanceTimers: true }); class MockNavigationContainer { @@ -82,6 +84,85 @@ describe('ReactNavigationInstrumentation', () => { ); }); + describe('initial navigation span is created after all integrations are setup', () => { + let rnTracing: ReturnType; + + beforeEach(() => { + const startFrames = { + totalFrames: 100, + slowFrames: 20, + frozenFrames: 5, + }; + const finishFrames = { + totalFrames: 200, + slowFrames: 40, + frozenFrames: 10, + }; + NATIVE.fetchNativeFrames.mockResolvedValueOnce(startFrames).mockResolvedValueOnce(finishFrames); + + const rNavigation = new ReactNavigationInstrumentation({ + routeChangeTimeoutMs: 200, + }); + mockNavigation = createMockNavigationAndAttachTo(rNavigation); + + rnTracing = reactNativeTracingIntegration({ + routingInstrumentation: rNavigation, + }); + }); + + test('initial navigation span contains native frames when nativeFrames integration is after react native tracing', async () => { + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: true, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [rnTracing, nativeFramesIntegration()], + enableAppStartTracking: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // Flush the init transaction, must be async to allow for the native start frames to be fetched + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expectInitNavigationSpanWithNativeFrames(client.event); + }); + + test('initial navigation span contains native frames when nativeFrames integration is before react native tracing', async () => { + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: true, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [nativeFramesIntegration(), rnTracing], + enableAppStartTracking: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // Flush the init transaction, must be async to allow for the native start frames to be fetched + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expectInitNavigationSpanWithNativeFrames(client.event); + }); + + function expectInitNavigationSpanWithNativeFrames(event: Event): void { + expect(event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Initial Screen', + measurements: expect.objectContaining({ + frames_total: expect.toBeObject(), + frames_slow: expect.toBeObject(), + frames_frozen: expect.toBeObject(), + }), + }), + ); + } + }); + test('transaction sent on navigation', async () => { setupTestClient(); jest.runOnlyPendingTimers(); // Flush the init transaction From 5d98deda574ae16a24839a12d07f0288da27c0ce Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 9 Aug 2024 16:37:29 +0200 Subject: [PATCH 37/37] fix background test --- test/tracing/reactnativetracing.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 916489b1cc..9b84f14b3d 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -129,6 +129,7 @@ describe('ReactNativeTracing', () => { }); integration.setup(client); + integration.afterAllSetup(client); // wait for internal promises to resolve, fetch app start data from mocked native await Promise.resolve();