From c3dcfd3cc59413bf47ca96657f3543eda9fe41fb Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 3 Jun 2024 18:39:03 +0200 Subject: [PATCH 01/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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 0d75b3ce7e47231570fbb48b0e0250c383d13a49 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Thu, 8 Aug 2024 18:13:38 +0200 Subject: [PATCH 37/51] wip: Refactor Navigation Integrations to use new function style --- src/js/tracing/index.ts | 2 +- src/js/tracing/reactnativetracing.ts | 45 +- src/js/tracing/reactnavigation.ts | 530 +++++++++++------------ src/js/tracing/routingInstrumentation.ts | 81 ---- test/tracing/reactnavigation.test.ts | 16 +- test/tracing/reactnavigationutils.ts | 4 +- 6 files changed, 271 insertions(+), 407 deletions(-) delete mode 100644 src/js/tracing/routingInstrumentation.ts diff --git a/src/js/tracing/index.ts b/src/js/tracing/index.ts index dc071fe236..444c5f1a30 100644 --- a/src/js/tracing/index.ts +++ b/src/js/tracing/index.ts @@ -8,7 +8,7 @@ export type { RoutingInstrumentationInstance } from './routingInstrumentation'; export { RoutingInstrumentation } from './routingInstrumentation'; export { - ReactNavigationInstrumentation, + reactNavigationIntegration as ReactNavigationInstrumentation, // eslint-disable-next-line deprecation/deprecation ReactNavigationV5Instrumentation, } from './reactnavigation'; diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index d4397c2a77..0eccd92a75 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -4,7 +4,6 @@ import { getClient, getCurrentScope } from '@sentry/core'; import type { Client, Event, Integration, StartSpanOptions } from '@sentry/types'; import { logger } from '@sentry/utils'; -import type { RoutingInstrumentationInstance } from './routingInstrumentation'; import { addDefaultOpForSpanFrom, startIdleNavigationSpan } from './span'; export const INTEGRATION_NAME = 'ReactNativeTracing'; @@ -47,12 +46,6 @@ export interface ReactNativeTracingOptions { */ enableHTTPTimings: boolean; - /** - * The routing instrumentation to be used with the tracing integration. - * There is no routing instrumentation if nothing is passed. - */ - routingInstrumentation?: RoutingInstrumentationInstance; - /** * 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. @@ -88,7 +81,7 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { ignoreEmptyBackNavigationTransactions: true, }; -type ReactNativeTracingState = { +export type ReactNativeTracingState = { currentRoute: string | undefined; }; @@ -97,6 +90,7 @@ export const reactNativeTracingIntegration = ( ): Integration & { options: ReactNativeTracingOptions; state: ReactNativeTracingState; + setCurrentRoute: (route: string) => void; } => { const state: ReactNativeTracingState = { currentRoute: undefined, @@ -121,37 +115,6 @@ export const reactNativeTracingIntegration = ( }); }; - const afterAllSetup = (): void => { - if (finalOptions.routingInstrumentation) { - const idleNavigationSpanOptions = { - finalTimeout: finalOptions.finalTimeoutMs, - idleTimeout: finalOptions.idleTimeoutMs, - ignoreEmptyBackNavigationTransactions: finalOptions.ignoreEmptyBackNavigationTransactions, - }; - finalOptions.routingInstrumentation.registerRoutingInstrumentation( - navigationInstrumentationOptions => - startIdleNavigationSpan( - finalOptions.beforeStartSpan({ - name: DEFAULT_NAVIGATION_SPAN_NAME, - op: 'navigation', - forceTransaction: true, - scope: getCurrentScope(), - ...navigationInstrumentationOptions, - }), - idleNavigationSpanOptions, - ), - () => { - // no-op, replaced by beforeStartSpan, will be removed in the future - }, - (currentViewName: string | undefined) => { - state.currentRoute = currentViewName; - }, - ); - } else { - logger.log(`[${INTEGRATION_NAME}] Not instrumenting route changes as routingInstrumentation has not been set.`); - } - }; - const processEvent = (event: Event): Event => { if (event.contexts && state.currentRoute) { event.contexts.app = { view_names: [state.currentRoute], ...event.contexts.app }; @@ -162,10 +125,12 @@ export const reactNativeTracingIntegration = ( return { name: INTEGRATION_NAME, setup, - afterAllSetup, processEvent, options: finalOptions, state, + setCurrentRoute: (route: string) => { + state.currentRoute = route; + }, }; }; diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index 965f7980d0..caf57c803d 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -8,40 +8,30 @@ import { spanToJSON, startInactiveSpan, } from '@sentry/core'; -import type { Span } from '@sentry/types'; -import { logger, timestampInSeconds } from '@sentry/utils'; +import type { Client, Integration, Span } from '@sentry/types'; +import { isPlainObject, logger, timestampInSeconds } from '@sentry/utils'; import type { NewFrameEvent } from '../utils/sentryeventemitter'; import { type SentryEventEmitter, createSentryEventEmitter, NewFrameEventName } from '../utils/sentryeventemitter'; 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 type { ReactNativeTracingState } from './reactnativetracing'; +import { DEFAULT_NAVIGATION_SPAN_NAME, getReactNativeTracingIntegration } from './reactnativetracing'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; +import { startIdleNavigationSpan as startGenericIdleNavigationSpan } from './span'; import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan } from './timetodisplay'; -import type { BeforeNavigate } from './types'; -export interface NavigationRoute { - name: string; - key: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - params?: Record; -} +export const INTEGRATION_NAME = 'ReactNavigation'; -interface NavigationContainer { - addListener: (type: string, listener: () => void) => void; - getCurrentRoute: () => NavigationRoute; -} +const NAVIGATION_HISTORY_MAX_SIZE = 200; -interface ReactNavigationOptions { +interface ReactNavigationIntegrationOptions { /** * How long the instrumentation will wait for the route to mount after a change has been initiated, * before the transaction is discarded. - * Time is in ms. * - * @default 1000 + * @default 1_000 (ms) */ routeChangeTimeoutMs: number; @@ -54,11 +44,6 @@ interface ReactNavigationOptions { enableTimeToInitialDisplay: boolean; } -const defaultOptions: ReactNavigationOptions = { - routeChangeTimeoutMs: 1000, - enableTimeToInitialDisplay: false, -}; - /** * Instrumentation for React-Navigation V5 and above. See docs or sample app for usage. * @@ -67,317 +52,312 @@ const defaultOptions: ReactNavigationOptions = { * - `_onStateChange` is then called AFTER the state change happens due to a dispatch and sets the route context onto the active transaction. * - If `_onStateChange` isn't called within `STATE_CHANGE_TIMEOUT_DURATION` of the dispatch, then the transaction is not sampled and finished. */ -export class ReactNavigationInstrumentation extends InternalRoutingInstrumentation { - public static instrumentationName: string = 'react-navigation-v5'; - - public readonly name: string = ReactNavigationInstrumentation.instrumentationName; - - private _navigationContainer: NavigationContainer | null = null; - private _newScreenFrameEventEmitter: SentryEventEmitter | null = null; - - private readonly _maxRecentRouteLen: number = 200; - - private _latestRoute?: NavigationRoute; - private _latestTransaction?: Span; - private _navigationProcessingSpan?: Span; - - private _initialStateHandled: boolean = false; - private _stateChangeTimeout?: number | undefined; - private _recentRouteKeys: string[] = []; +export const reactNavigationIntegration = ({ + routeChangeTimeoutMs = 1_000, + enableTimeToInitialDisplay = false, +}: Partial = {}): Integration & { + /** + * Pass the ref to the navigation container to register it to the instrumentation + * @param navigationContainerRef Ref to a `NavigationContainer` + */ + registerNavigationContainer: (navigationContainerRef: unknown) => void; +} => { + let navigationContainer: NavigationContainer | undefined; + let newScreenFrameEventEmitter: SentryEventEmitter | undefined; + + let latestRoute: NavigationRoute | undefined; + + let latestTransaction: Span | undefined; + let navigationProcessingSpan: Span | undefined; + + let initialStateHandled: boolean = false; + let stateChangeTimeout: ReturnType | undefined; + let recentRouteKeys: string[] = []; + + if (enableTimeToInitialDisplay) { + newScreenFrameEventEmitter = createSentryEventEmitter(); + newScreenFrameEventEmitter.initAsync(NewFrameEventName); + NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => { + logger.error(`[ReactNavigationInstrumentation] Failed to initialize native new frame tracking: ${reason}`); + }); + } - private _options: ReactNavigationOptions; + /** + * Set the initial state and start initial navigation span for the current screen. + */ + const afterAllSetup = (client: Client): void => { + const tracing = getReactNativeTracingIntegration(client); + if (tracing) { + tracingStateRef = tracing.state; + } - public constructor(options: Partial = {}) { - super(); + if (initialStateHandled) { + // We create an initial state here to ensure a transaction gets created before the first route mounts. + return undefined; + } - this._options = { - ...defaultOptions, - ...options, - }; + _startIdleNavigationSpan(); - if (this._options.enableTimeToInitialDisplay) { - this._newScreenFrameEventEmitter = createSentryEventEmitter(); - this._newScreenFrameEventEmitter.initAsync(NewFrameEventName); - NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => { - logger.error(`[ReactNavigationInstrumentation] Failed to initialize native new frame tracking: ${reason}`); - }); + if (!navigationContainer) { + // This is expected as navigation container is registered after the root component is mounted. + return undefined; } - } - /** - * Extends by calling _handleInitialState at the end. - */ - public registerRoutingInstrumentation( - listener: TransactionCreator, - beforeNavigate: BeforeNavigate, - onConfirmRoute: OnConfirmRoute, - ): void { - super.registerRoutingInstrumentation(listener, beforeNavigate, onConfirmRoute); - - // We create an initial state here to ensure a transaction gets created before the first route mounts. - if (!this._initialStateHandled) { - this._onDispatch(); - if (this._navigationContainer) { - // Navigation container already registered, just populate with route state - this._onStateChange(); - - this._initialStateHandled = true; - } - } - } + // Navigation container already registered, just populate with route state + updateLatestNavigationSpanWithCurrentRoute(); + initialStateHandled = true; + }; - /** - * Pass the ref to the navigation container to register it to the instrumentation - * @param navigationContainerRef Ref to a `NavigationContainer` - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types - public registerNavigationContainer(navigationContainerRef: any): void { + const registerNavigationContainer = (navigationContainerRef: unknown): void => { /* We prevent duplicate routing instrumentation to be initialized on fast refreshes Explanation: If the user triggers a fast refresh on the file that the instrumentation is initialized in, it will initialize a new instance and will cause undefined behavior. */ - if (!RN_GLOBAL_OBJ.__sentry_rn_v5_registered) { - if ('current' in navigationContainerRef) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this._navigationContainer = navigationContainerRef.current; - } else { - this._navigationContainer = navigationContainerRef; - } - - if (this._navigationContainer) { - this._navigationContainer.addListener( - '__unsafe_action__', // This action is emitted on every dispatch - this._onDispatch.bind(this), - ); - this._navigationContainer.addListener( - 'state', // This action is emitted on every state change - this._onStateChange.bind(this), - ); - - if (!this._initialStateHandled) { - if (this._latestTransaction) { - // If registerRoutingInstrumentation was called first _onDispatch has already been called - this._onStateChange(); - - this._initialStateHandled = true; - } else { - logger.log( - '[ReactNavigationInstrumentation] Navigation container registered, but integration has not been setup yet.', - ); - } - } + if (RN_GLOBAL_OBJ.__sentry_rn_v5_registered) { + logger.log( + '[ReactNavigationInstrumentation] Instrumentation already exists, but register has been called again, doing nothing.', + ); + return undefined; + } - RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; - } else { - logger.warn('[ReactNavigationInstrumentation] Received invalid navigation container ref!'); - } + if (isPlainObject(navigationContainerRef) && 'current' in navigationContainerRef) { + navigationContainer = navigationContainerRef.current as NavigationContainer; } else { + navigationContainer = navigationContainerRef as NavigationContainer; + } + if (!navigationContainer) { + logger.warn('[ReactNavigationInstrumentation] Received invalid navigation container ref!'); + return undefined; + } + + // This action is emitted on every dispatch + navigationContainer.addListener('__unsafe_action__', _startIdleNavigationSpan); + navigationContainer.addListener('state', updateLatestNavigationSpanWithCurrentRoute); + RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; + + if (initialStateHandled) { + return undefined; + } + + if (!latestTransaction) { logger.log( - '[ReactNavigationInstrumentation] Instrumentation already exists, but register has been called again, doing nothing.', + '[ReactNavigationInstrumentation] Navigation container registered, but integration has not been setup yet.', ); + return undefined; } - } + + // Navigation Container is registered after the first navigation + // Initial navigation span was started, after integration setup, + // so now we populate it with the current route. + updateLatestNavigationSpanWithCurrentRoute(); + initialStateHandled = true; + }; /** * To be called on every React-Navigation action dispatch. * It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change - * and gets the route information from there, @see _onStateChange + * and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute */ - private _onDispatch(): void { - if (this._latestTransaction) { + const _startIdleNavigationSpan = (): void => { + if (latestTransaction) { logger.log( '[ReactNavigationInstrumentation] A transaction was detected that turned out to be a noop, discarding.', ); - this._discardLatestTransaction(); - this._clearStateChangeTimeout(); + _discardLatestTransaction(); + _clearStateChangeTimeout(); } - this._latestTransaction = this.onRouteWillChange({ name: DEFAULT_NAVIGATION_SPAN_NAME }); + latestTransaction = startGenericIdleNavigationSpan( + // TODO: call before start span + { name: DEFAULT_NAVIGATION_SPAN_NAME }, + { + finalTimeout: 60_000, // TODO: react from tracing options + idleTimeout: 1_000, // TODO: react from tracing options + ignoreEmptyBackNavigationTransactions: false, // TODO: react from nav options + }, + ); - if (this._options.enableTimeToInitialDisplay) { - this._navigationProcessingSpan = startInactiveSpan({ + if (enableTimeToInitialDisplay) { + navigationProcessingSpan = startInactiveSpan({ op: 'navigation.processing', name: 'Navigation processing', - startTime: this._latestTransaction && spanToJSON(this._latestTransaction).start_timestamp, + startTime: latestTransaction && spanToJSON(latestTransaction).start_timestamp, }); } - this._stateChangeTimeout = setTimeout( - this._discardLatestTransaction.bind(this), - this._options.routeChangeTimeoutMs, - ); - } + stateChangeTimeout = setTimeout(_discardLatestTransaction, routeChangeTimeoutMs); + }; /** * To be called AFTER the state has been changed to populate the transaction with the current route. */ - private _onStateChange(): void { + const updateLatestNavigationSpanWithCurrentRoute = (): void => { const stateChangedTimestamp = timestampInSeconds(); + const previousRoute = latestRoute; + + if (!navigationContainer) { + logger.warn(`${INTEGRATION_NAME} Missing navigation container ref. Route transactions will not be sent.`); + return undefined; + } - // Use the getCurrentRoute method to be accurate. - const previousRoute = this._latestRoute; + const route = navigationContainer.getCurrentRoute(); + if (!route) { + logger.debug(`[${INTEGRATION_NAME}] Navigation state changed, but no route is rendered.`); + return undefined; + } - if (!this._navigationContainer) { - logger.warn( - '[ReactNavigationInstrumentation] Missing navigation container ref. Route transactions will not be sent.', + if (!latestTransaction) { + logger.debug( + `[${INTEGRATION_NAME}] Navigation state changed, but navigation transaction was not started on dispatch.`, ); + return undefined; + } + + if (previousRoute && previousRoute.key === route.key) { + logger.debug(`[${INTEGRATION_NAME}] Navigation state changed, but route is the same as previous.`); + _pushRecentRouteKey(route.key); + latestRoute = route; - return; + // Clear the latest transaction as it has been handled. + latestTransaction = undefined; + return undefined; } - const route = this._navigationContainer.getCurrentRoute(); - - if (route) { - if (this._latestTransaction) { - if (!previousRoute || previousRoute.key !== route.key) { - const routeHasBeenSeen = this._recentRouteKeys.includes(route.key); - const latestTtidSpan = - !routeHasBeenSeen && - this._options.enableTimeToInitialDisplay && - startTimeToInitialDisplaySpan({ - name: `${route.name} initial display`, - isAutoInstrumented: true, - }); - - !routeHasBeenSeen && - this._newScreenFrameEventEmitter?.once( - NewFrameEventName, - ({ newFrameTimestampInSeconds }: NewFrameEvent) => { - const activeSpan = getActiveSpan(); - if (!activeSpan) { - logger.warn( - '[ReactNavigationInstrumentation] No active span found to attach ui.load.initial_display to.', - ); - return; - } - - if (manualInitialDisplaySpans.has(activeSpan)) { - logger.warn( - '[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.', - ); - return; - } - - if (!latestTtidSpan) { - return; - } - - if (spanToJSON(latestTtidSpan).parent_span_id !== getActiveSpan()?.spanContext().spanId) { - logger.warn( - '[ReactNavigationInstrumentation] Currently Active Span changed before the new frame was rendered, _latestTtidSpan is not a child of the currently active span.', - ); - return; - } - - latestTtidSpan.setStatus({ code: SPAN_STATUS_OK }); - latestTtidSpan.end(newFrameTimestampInSeconds); - const ttidSpan = spanToJSON(latestTtidSpan); - - const ttidSpanEnd = ttidSpan.timestamp; - const ttidSpanStart = ttidSpan.start_timestamp; - if (!ttidSpanEnd || !ttidSpanStart) { - return; - } - - setMeasurement('time_to_initial_display', (ttidSpanEnd - ttidSpanStart) * 1000, 'millisecond'); - }, - ); - - this._navigationProcessingSpan?.updateName(`Processing navigation to ${route.name}`); - this._navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK }); - this._navigationProcessingSpan?.end(stateChangedTimestamp); - this._navigationProcessingSpan = undefined; - - 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, - // TODO: filter PII params instead of dropping them all - // 'route.params': {}, - 'route.has_been_seen': routeHasBeenSeen, - 'previous_route.name': previousRoute?.name, - 'previous_route.key': previousRoute?.key, - // TODO: filter PII params instead of dropping them all - // 'previous_route.params': {}, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - }); - - // Clear the timeout so the transaction does not get cancelled. - this._clearStateChangeTimeout(); - - this._onConfirmRoute?.(route.name); - - // TODO: Add test for addBreadcrumb - addBreadcrumb({ - category: 'navigation', - type: 'navigation', - message: `Navigation to ${route.name}`, - data: { - from: previousRoute?.name, - to: route.name, - }, - }); + const routeHasBeenSeen = recentRouteKeys.includes(route.key); + const latestTtidSpan = + !routeHasBeenSeen && + enableTimeToInitialDisplay && + startTimeToInitialDisplaySpan({ + name: `${route.name} initial display`, + isAutoInstrumented: true, + }); + + !routeHasBeenSeen && + newScreenFrameEventEmitter?.once(NewFrameEventName, ({ newFrameTimestampInSeconds }: NewFrameEvent) => { + const activeSpan = getActiveSpan(); + if (!activeSpan) { + logger.warn('[ReactNavigationInstrumentation] No active span found to attach ui.load.initial_display to.'); + return; } - this._pushRecentRouteKey(route.key); - this._latestRoute = route; + if (manualInitialDisplaySpans.has(activeSpan)) { + logger.warn('[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.'); + return; + } - // Clear the latest transaction as it has been handled. - this._latestTransaction = undefined; - } + if (!latestTtidSpan) { + return; + } + + if (spanToJSON(latestTtidSpan).parent_span_id !== getActiveSpan()?.spanContext().spanId) { + logger.warn( + '[ReactNavigationInstrumentation] Currently Active Span changed before the new frame was rendered, _latestTtidSpan is not a child of the currently active span.', + ); + return; + } + + latestTtidSpan.setStatus({ code: SPAN_STATUS_OK }); + latestTtidSpan.end(newFrameTimestampInSeconds); + const ttidSpan = spanToJSON(latestTtidSpan); + + const ttidSpanEnd = ttidSpan.timestamp; + const ttidSpanStart = ttidSpan.start_timestamp; + if (!ttidSpanEnd || !ttidSpanStart) { + return; + } + + setMeasurement('time_to_initial_display', (ttidSpanEnd - ttidSpanStart) * 1000, 'millisecond'); + }); + + navigationProcessingSpan?.updateName(`Processing navigation to ${route.name}`); + navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK }); + navigationProcessingSpan?.end(stateChangedTimestamp); + navigationProcessingSpan = undefined; + + if (spanToJSON(latestTransaction).description === DEFAULT_NAVIGATION_SPAN_NAME) { + latestTransaction.updateName(route.name); } - } + latestTransaction.setAttributes({ + 'route.name': route.name, + 'route.key': route.key, + // TODO: filter PII params instead of dropping them all + // 'route.params': {}, + 'route.has_been_seen': routeHasBeenSeen, + 'previous_route.name': previousRoute?.name, + 'previous_route.key': previousRoute?.key, + // TODO: filter PII params instead of dropping them all + // 'previous_route.params': {}, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }); + + // Clear the timeout so the transaction does not get cancelled. + _clearStateChangeTimeout(); + + // TODO: Add test for addBreadcrumb + addBreadcrumb({ + category: 'navigation', + type: 'navigation', + message: `Navigation to ${route.name}`, + data: { + from: previousRoute?.name, + to: route.name, + }, + }); + + _pushRecentRouteKey(route.key); + latestRoute = route; + // Clear the latest transaction as it has been handled. + latestTransaction = undefined; + }; /** Pushes a recent route key, and removes earlier routes when there is greater than the max length */ - private _pushRecentRouteKey = (key: string): void => { - this._recentRouteKeys.push(key); + const _pushRecentRouteKey = (key: string): void => { + recentRouteKeys.push(key); - if (this._recentRouteKeys.length > this._maxRecentRouteLen) { - this._recentRouteKeys = this._recentRouteKeys.slice(this._recentRouteKeys.length - this._maxRecentRouteLen); + if (recentRouteKeys.length > NAVIGATION_HISTORY_MAX_SIZE) { + recentRouteKeys = recentRouteKeys.slice(recentRouteKeys.length - NAVIGATION_HISTORY_MAX_SIZE); } }; /** Cancels the latest transaction so it does not get sent to Sentry. */ - private _discardLatestTransaction(): void { - if (this._latestTransaction) { - if (isSentrySpan(this._latestTransaction)) { - this._latestTransaction['_sampled'] = false; + const _discardLatestTransaction = (): void => { + if (latestTransaction) { + if (isSentrySpan(latestTransaction)) { + latestTransaction['_sampled'] = false; } // TODO: What if it's not SentrySpan? - this._latestTransaction.end(); - this._latestTransaction = undefined; + latestTransaction.end(); + latestTransaction = undefined; } - if (this._navigationProcessingSpan) { - this._navigationProcessingSpan = undefined; + if (navigationProcessingSpan) { + navigationProcessingSpan = undefined; } - } + }; - /** - * - */ - private _clearStateChangeTimeout(): void { - if (typeof this._stateChangeTimeout !== 'undefined') { - clearTimeout(this._stateChangeTimeout); - this._stateChangeTimeout = undefined; + const _clearStateChangeTimeout = (): void => { + if (typeof stateChangeTimeout !== 'undefined') { + clearTimeout(stateChangeTimeout); + stateChangeTimeout = undefined; } - } -} + }; -/** - * Backwards compatibility alias for ReactNavigationInstrumentation - * @deprecated Use ReactNavigationInstrumentation - */ -export const ReactNavigationV5Instrumentation = ReactNavigationInstrumentation; - -export const BLANK_TRANSACTION_CONTEXT = { - name: 'Route Change', - op: 'navigation', - tags: { - 'routing.instrumentation': ReactNavigationInstrumentation.instrumentationName, - }, - data: {}, + return { + name: INTEGRATION_NAME, + afterAllSetup, + registerNavigationContainer, + }; }; + +export interface NavigationRoute { + name: string; + key: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: Record; +} + +interface NavigationContainer { + addListener: (type: string, listener: () => void) => void; + getCurrentRoute: () => NavigationRoute; +} diff --git a/src/js/tracing/routingInstrumentation.ts b/src/js/tracing/routingInstrumentation.ts deleted file mode 100644 index 8655743499..0000000000 --- a/src/js/tracing/routingInstrumentation.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { Span, StartSpanOptions } from '@sentry/types'; - -import type { BeforeNavigate } from './types'; - -export type TransactionCreator = (context: StartSpanOptions) => Span | undefined; - -export type OnConfirmRoute = (currentViewName: string | undefined) => void; - -export interface RoutingInstrumentationInstance { - /** - * Name of the routing instrumentation - */ - readonly name: string; - /** - * Registers a listener that's called on every route change with a `TransactionContext`. - * - * Do not overwrite this unless you know what you are doing. - * - * @param listener A `RouteListener` - * @param beforeNavigate BeforeNavigate - * @param inConfirmRoute OnConfirmRoute - */ - registerRoutingInstrumentation( - listener: TransactionCreator, - beforeNavigate: BeforeNavigate, - onConfirmRoute: OnConfirmRoute, - ): void; - /** - * To be called when the route changes, BEFORE the new route mounts. - * If this is called after a route mounts the child spans will not be correctly attached. - * - * @param context A `TransactionContext` used to initialize the transaction. - */ - onRouteWillChange(context: StartSpanOptions): Span | undefined; -} - -/** - * Base Routing Instrumentation. Can be used by users to manually instrument custom routers. - * Pass this to the tracing integration, and call `onRouteWillChange` every time before a route changes. - */ -export class RoutingInstrumentation implements RoutingInstrumentationInstance { - public static instrumentationName: string = 'base-routing-instrumentation'; - - public readonly name: string = RoutingInstrumentation.instrumentationName; - - protected _beforeNavigate?: BeforeNavigate; - protected _onConfirmRoute?: OnConfirmRoute; - protected _tracingListener?: TransactionCreator; - - /** @inheritdoc */ - public registerRoutingInstrumentation( - listener: TransactionCreator, - beforeNavigate: BeforeNavigate, - onConfirmRoute: OnConfirmRoute, - ): void { - this._tracingListener = listener; - this._beforeNavigate = beforeNavigate; - this._onConfirmRoute = onConfirmRoute; - } - - /** @inheritdoc */ - public onRouteWillChange(context: StartSpanOptions): Span | undefined { - const transaction = this._tracingListener?.(context); - - if (transaction) { - this._onConfirmRoute?.(context.name); - } - - return transaction; - } -} - -/** - * Internal base routing instrumentation where `_onConfirmRoute` is not called in onRouteWillChange - */ -export class InternalRoutingInstrumentation extends RoutingInstrumentation { - /** @inheritdoc */ - public onRouteWillChange(context: StartSpanOptions): Span | undefined { - return this._tracingListener?.(context); - } -} diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index 9806aa891f..7ce0afa599 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -6,7 +6,7 @@ import type { Event, Measurements, StartSpanOptions } from '@sentry/types'; 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'; +import { reactNavigationIntegration } from '../../src/js/tracing/reactnavigation'; import { SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_KEY, SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME, @@ -100,7 +100,7 @@ describe('ReactNavigationInstrumentation', () => { }; NATIVE.fetchNativeFrames.mockResolvedValueOnce(startFrames).mockResolvedValueOnce(finishFrames); - const rNavigation = new ReactNavigationInstrumentation({ + const rNavigation = new reactNavigationIntegration({ routeChangeTimeoutMs: 200, }); mockNavigation = createMockNavigationAndAttachTo(rNavigation); @@ -314,7 +314,7 @@ describe('ReactNavigationInstrumentation', () => { describe('navigation container registration', () => { test('registers navigation container object ref', () => { - const instrumentation = new ReactNavigationInstrumentation(); + const instrumentation = new reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer({ current: mockNavigationContainer, @@ -327,7 +327,7 @@ describe('ReactNavigationInstrumentation', () => { }); test('registers navigation container direct ref', () => { - const instrumentation = new ReactNavigationInstrumentation(); + const instrumentation = new reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer(mockNavigationContainer); @@ -340,7 +340,7 @@ describe('ReactNavigationInstrumentation', () => { test('does not register navigation container if there is an existing one', () => { RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; - const instrumentation = new ReactNavigationInstrumentation(); + const instrumentation = new reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer({ current: mockNavigationContainer, @@ -353,7 +353,7 @@ describe('ReactNavigationInstrumentation', () => { }); test('works if routing instrumentation registration is after navigation registration', async () => { - const instrumentation = new ReactNavigationInstrumentation(); + const instrumentation = new reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer(mockNavigationContainer); @@ -374,7 +374,7 @@ describe('ReactNavigationInstrumentation', () => { describe('options', () => { test('waits until routeChangeTimeoutMs', () => { - const instrumentation = new ReactNavigationInstrumentation({ + const instrumentation = new reactNavigationIntegration({ routeChangeTimeoutMs: 200, }); @@ -409,7 +409,7 @@ describe('ReactNavigationInstrumentation', () => { beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions; } = {}, ) { - const rNavigation = new ReactNavigationInstrumentation({ + const rNavigation = new reactNavigationIntegration({ routeChangeTimeoutMs: 200, }); mockNavigation = createMockNavigationAndAttachTo(rNavigation); diff --git a/test/tracing/reactnavigationutils.ts b/test/tracing/reactnavigationutils.ts index 5bdfa5f199..7c15b6acac 100644 --- a/test/tracing/reactnavigationutils.ts +++ b/test/tracing/reactnavigationutils.ts @@ -1,6 +1,6 @@ -import type { NavigationRoute, ReactNavigationInstrumentation } from '../../src/js/tracing/reactnavigation'; +import type { NavigationRoute, reactNavigationIntegration } from '../../src/js/tracing/reactnavigation'; -export function createMockNavigationAndAttachTo(sut: ReactNavigationInstrumentation) { +export function createMockNavigationAndAttachTo(sut: reactNavigationIntegration) { const mockedNavigationContained = mockNavigationContainer(); const mockedNavigation = { emitCancelledNavigation: () => { From 5d98deda574ae16a24839a12d07f0288da27c0ce Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 9 Aug 2024 16:37:29 +0200 Subject: [PATCH 38/51] 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(); From 8a71a81d293d9129fb19737042b68d2e2698e182 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 9 Aug 2024 18:57:40 +0200 Subject: [PATCH 39/51] new react navigation interface impl --- samples/expo/app/_layout.tsx | 9 ++-- samples/react-native/src/App.tsx | 15 +++--- src/js/client.ts | 14 ------ src/js/index.ts | 4 +- src/js/tracing/index.ts | 9 +--- src/js/tracing/integrations/appStart.ts | 12 ++--- src/js/tracing/reactnativenavigation.ts | 21 ++------- src/js/tracing/reactnativetracing.ts | 16 ++----- src/js/tracing/reactnavigation.ts | 46 +++++++++++++------ test/tracing/gesturetracing.test.ts | 2 - .../integrations/userInteraction.test.ts | 2 - test/tracing/mockedrountinginstrumention.ts | 24 ---------- test/tracing/reactnativetracing.test.ts | 2 - 13 files changed, 57 insertions(+), 119 deletions(-) delete mode 100644 test/tracing/mockedrountinginstrumention.ts diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index 539dbb0fad..bdb9ed49ca 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -21,7 +21,7 @@ LogBox.ignoreAllLogs(); // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); -const routingInstrumentation = new Sentry.ReactNavigationInstrumentation({ +const navigationIntegration = Sentry.reactNavigationIntegration({ enableTimeToInitialDisplay: !isExpoGo(), // This is not supported in Expo Go. }); @@ -54,9 +54,8 @@ process.env.EXPO_SKIP_DURING_EXPORT !== 'true' && Sentry.init({ // default: [/.*/] failedRequestTargets: [/.*/], }), - Sentry.reactNativeTracingIntegration({ - routingInstrumentation, - }), + navigationIntegration, + Sentry.reactNativeTracingIntegration(), ); return integrations.filter(i => i.name !== 'Dedupe'); }, @@ -91,7 +90,7 @@ function RootLayout() { useEffect(() => { if (ref) { - routingInstrumentation.registerNavigationContainer(ref); + navigationIntegration.registerNavigationContainer(ref); } }, [ref]); diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 1a5dfa0440..3251dc4948 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -38,11 +38,11 @@ LogBox.ignoreAllLogs(); const isMobileOs = Platform.OS === 'android' || Platform.OS === 'ios'; -const reactNavigationInstrumentation = - new Sentry.ReactNavigationInstrumentation({ - routeChangeTimeoutMs: 500, // How long it will wait for the route change to complete. Default is 1000ms - enableTimeToInitialDisplay: isMobileOs, - }); +const reactNavigationIntegration = Sentry.reactNavigationIntegration({ + routeChangeTimeoutMs: 500, // How long it will wait for the route change to complete. Default is 1000ms + enableTimeToInitialDisplay: isMobileOs, + ignoreEmptyBackNavigationTransactions: true, +}); Sentry.init({ // Replace the example DSN below with your own DSN: @@ -66,11 +66,10 @@ Sentry.init({ }, integrations(integrations) { integrations.push( + reactNavigationIntegration, Sentry.reactNativeTracingIntegration({ // The time to wait in ms until the transaction will be finished, For testing, default is 1000 ms idleTimeoutMs: 5_000, - routingInstrumentation: reactNavigationInstrumentation, - ignoreEmptyBackNavigationTransactions: true, }), Sentry.httpClientIntegration({ // These options are effective only in JS. @@ -180,7 +179,7 @@ function BottomTabs() { { - reactNavigationInstrumentation.registerNavigationContainer(navigation); + reactNavigationIntegration.registerNavigationContainer(navigation); }}> { this._initNativeSdk(); } - /** - * @inheritdoc - */ - protected _setupIntegrations(): void { - super._setupIntegrations(); - const tracing = getReactNativeTracingIntegration(this); - const routingName = tracing?.options?.routingInstrumentation?.name; - if (routingName) { - this.addIntegration(createIntegration(routingName)); - } - } - /** * Starts native client with dsn and options */ diff --git a/src/js/index.ts b/src/js/index.ts index 8854f73716..5244b7fbb7 100644 --- a/src/js/index.ts +++ b/src/js/index.ts @@ -59,10 +59,8 @@ export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; export { reactNativeTracingIntegration, - ReactNavigationV5Instrumentation, - ReactNavigationInstrumentation, + reactNavigationIntegration, ReactNativeNavigationInstrumentation, - RoutingInstrumentation, sentryTraceGesture, TimeToInitialDisplay, TimeToFullDisplay, diff --git a/src/js/tracing/index.ts b/src/js/tracing/index.ts index 444c5f1a30..e0bf02d7fe 100644 --- a/src/js/tracing/index.ts +++ b/src/js/tracing/index.ts @@ -4,14 +4,7 @@ export { } from './reactnativetracing'; export type { ReactNativeTracingIntegration } from './reactnativetracing'; -export type { RoutingInstrumentationInstance } from './routingInstrumentation'; -export { RoutingInstrumentation } from './routingInstrumentation'; - -export { - reactNavigationIntegration as ReactNavigationInstrumentation, - // eslint-disable-next-line deprecation/deprecation - ReactNavigationV5Instrumentation, -} from './reactnavigation'; +export { reactNavigationIntegration } from './reactnavigation'; export { ReactNativeNavigationInstrumentation } from './reactnativenavigation'; export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types'; diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index 809d16aff9..3b1365b04c 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -22,8 +22,6 @@ 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'; @@ -98,7 +96,7 @@ export function _clearRootComponentCreationTimestampMs(): void { * Adds AppStart spans from the native layer to the transaction event. */ export const appStartIntegration = ({ - standalone: standaloneUserOption, + standalone = false, }: { /** * Should the integration send App Start as a standalone root span (transaction)? @@ -109,7 +107,6 @@ export const appStartIntegration = ({ standalone?: boolean; } = {}): AppStartIntegration => { let _client: Client | undefined = undefined; - let standalone = standaloneUserOption; let isEnabled = true; let appStartDataFlushed = false; @@ -124,11 +121,8 @@ export const appStartIntegration = ({ } }; - const afterAllSetup = (client: Client): void => { - if (standaloneUserOption === undefined) { - // If not user defined, set based on the routing instrumentation presence - standalone = !getReactNativeTracingIntegration(client)?.options.routingInstrumentation; - } + const afterAllSetup = (_client: Client): void => { + // TODO: automatically set standalone based on the presence of the native layer navigation integration }; const processEvent = async (event: Event): Promise => { diff --git a/src/js/tracing/reactnativenavigation.ts b/src/js/tracing/reactnativenavigation.ts index b07d6caff2..1ec729f00f 100644 --- a/src/js/tracing/reactnativenavigation.ts +++ b/src/js/tracing/reactnativenavigation.ts @@ -9,9 +9,6 @@ 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'; interface ReactNativeNavigationOptions { /** @@ -74,7 +71,7 @@ export interface NavigationDelegate { * - `_onComponentWillAppear` is then called AFTER the state change happens due to a dispatch and sets the route context onto the active transaction. * - If `_onComponentWillAppear` isn't called within `options.routeChangeTimeoutMs` of the dispatch, then the transaction is not sampled and finished. */ -export class ReactNativeNavigationInstrumentation extends InternalRoutingInstrumentation { +export class ReactNativeNavigationInstrumentation { public static instrumentationName: string = 'react-native-navigation'; public readonly name: string = ReactNativeNavigationInstrumentation.instrumentationName; @@ -93,8 +90,6 @@ export class ReactNativeNavigationInstrumentation extends InternalRoutingInstrum navigation: unknown, options: Partial = {}, ) { - super(); - this._navigation = navigation as NavigationDelegate; this._options = { @@ -106,13 +101,7 @@ export class ReactNativeNavigationInstrumentation extends InternalRoutingInstrum /** * Registers the event listeners for React Native Navigation */ - public registerRoutingInstrumentation( - listener: TransactionCreator, - beforeNavigate: BeforeNavigate, - onConfirmRoute: OnConfirmRoute, - ): void { - super.registerRoutingInstrumentation(listener, beforeNavigate, onConfirmRoute); - + public registerRoutingInstrumentation(): void { this._navigation.events().registerCommandListener(this._onNavigation.bind(this)); if (this._options.enableTabsInstrumentation) { @@ -130,7 +119,7 @@ export class ReactNativeNavigationInstrumentation extends InternalRoutingInstrum this._discardLatestTransaction(); } - this._latestTransaction = this.onRouteWillChange({ name: DEFAULT_NAVIGATION_SPAN_NAME }); + // this._latestTransaction = this.onRouteWillChange({ name: DEFAULT_NAVIGATION_SPAN_NAME }); this._stateChangeTimeout = setTimeout( this._discardLatestTransaction.bind(this), @@ -173,9 +162,9 @@ export class ReactNativeNavigationInstrumentation extends InternalRoutingInstrum [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }); - this._beforeNavigate?.(this._latestTransaction); + // this._beforeNavigate?.(this._latestTransaction); - this._onConfirmRoute?.(event.componentName); + // this._onConfirmRoute?.(event.componentName); addBreadcrumb({ category: 'navigation', diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 0eccd92a75..1d735b119c 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -1,10 +1,9 @@ /* eslint-disable max-lines */ import { instrumentOutgoingRequests } from '@sentry/browser'; -import { getClient, getCurrentScope } from '@sentry/core'; +import { getClient } from '@sentry/core'; import type { Client, Event, Integration, StartSpanOptions } from '@sentry/types'; -import { logger } from '@sentry/utils'; -import { addDefaultOpForSpanFrom, startIdleNavigationSpan } from './span'; +import { addDefaultOpForSpanFrom } from './span'; export const INTEGRATION_NAME = 'ReactNativeTracing'; @@ -46,14 +45,6 @@ export interface ReactNativeTracingOptions { */ enableHTTPTimings: boolean; - /** - * 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 - */ - ignoreEmptyBackNavigationTransactions: boolean; - /** * 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. @@ -72,13 +63,12 @@ export interface ReactNativeTracingOptions { const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; export const DEFAULT_NAVIGATION_SPAN_NAME = 'Route Change'; -const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { +export const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { idleTimeoutMs: 1_000, finalTimeoutMs: 60_0000, traceFetch: true, traceXHR: true, enableHTTPTimings: true, - ignoreEmptyBackNavigationTransactions: true, }; export type ReactNativeTracingState = { diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index caf57c803d..8cda99ba7c 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -16,8 +16,12 @@ import { type SentryEventEmitter, createSentryEventEmitter, NewFrameEventName } import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { NATIVE } from '../wrapper'; -import type { ReactNativeTracingState } from './reactnativetracing'; -import { DEFAULT_NAVIGATION_SPAN_NAME, getReactNativeTracingIntegration } from './reactnativetracing'; +import type { ReactNativeTracingIntegration } from './reactnativetracing'; +import { + DEFAULT_NAVIGATION_SPAN_NAME, + defaultReactNativeTracingOptions, + getReactNativeTracingIntegration, +} from './reactnativetracing'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { startIdleNavigationSpan as startGenericIdleNavigationSpan } from './span'; import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan } from './timetodisplay'; @@ -42,6 +46,14 @@ interface ReactNavigationIntegrationOptions { * @default false */ enableTimeToInitialDisplay: boolean; + + /** + * 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 + */ + ignoreEmptyBackNavigationTransactions: boolean; } /** @@ -55,6 +67,7 @@ interface ReactNavigationIntegrationOptions { export const reactNavigationIntegration = ({ routeChangeTimeoutMs = 1_000, enableTimeToInitialDisplay = false, + ignoreEmptyBackNavigationTransactions = true, }: Partial = {}): Integration & { /** * Pass the ref to the navigation container to register it to the instrumentation @@ -65,6 +78,12 @@ export const reactNavigationIntegration = ({ let navigationContainer: NavigationContainer | undefined; let newScreenFrameEventEmitter: SentryEventEmitter | undefined; + let tracing: ReactNativeTracingIntegration | undefined; + let idleSpanOptions: Parameters[1] = { + finalTimeout: defaultReactNativeTracingOptions.finalTimeoutMs, + idleTimeout: defaultReactNativeTracingOptions.idleTimeoutMs, + ignoreEmptyBackNavigationTransactions, + }; let latestRoute: NavigationRoute | undefined; let latestTransaction: Span | undefined; @@ -86,10 +105,12 @@ export const reactNavigationIntegration = ({ * Set the initial state and start initial navigation span for the current screen. */ const afterAllSetup = (client: Client): void => { - const tracing = getReactNativeTracingIntegration(client); - if (tracing) { - tracingStateRef = tracing.state; - } + tracing = getReactNativeTracingIntegration(client); + idleSpanOptions = { + finalTimeout: tracing.options.finalTimeoutMs, + idleTimeout: tracing.options.idleTimeoutMs, + ignoreEmptyBackNavigationTransactions, + }; if (initialStateHandled) { // We create an initial state here to ensure a transaction gets created before the first route mounts. @@ -169,13 +190,10 @@ export const reactNavigationIntegration = ({ } latestTransaction = startGenericIdleNavigationSpan( - // TODO: call before start span - { name: DEFAULT_NAVIGATION_SPAN_NAME }, - { - finalTimeout: 60_000, // TODO: react from tracing options - idleTimeout: 1_000, // TODO: react from tracing options - ignoreEmptyBackNavigationTransactions: false, // TODO: react from nav options - }, + tracing.options.beforeStartSpan + ? tracing.options.beforeStartSpan({ name: DEFAULT_NAVIGATION_SPAN_NAME }) + : { name: DEFAULT_NAVIGATION_SPAN_NAME }, + idleSpanOptions, ); if (enableTimeToInitialDisplay) { @@ -306,6 +324,8 @@ export const reactNavigationIntegration = ({ }, }); + tracing?.setCurrentRoute(route.key); + _pushRecentRouteKey(route.key); latestRoute = route; // Clear the latest transaction as it has been handled. diff --git a/test/tracing/gesturetracing.test.ts b/test/tracing/gesturetracing.test.ts index d0086827b2..49dc14c69a 100644 --- a/test/tracing/gesturetracing.test.ts +++ b/test/tracing/gesturetracing.test.ts @@ -11,8 +11,6 @@ import { startUserInteractionSpan } from '../../src/js/tracing/integrations/user 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'; jest.mock('../../src/js/wrapper', () => { return { diff --git a/test/tracing/integrations/userInteraction.test.ts b/test/tracing/integrations/userInteraction.test.ts index 01bcd86a0c..94b6ae31a1 100644 --- a/test/tracing/integrations/userInteraction.test.ts +++ b/test/tracing/integrations/userInteraction.test.ts @@ -18,8 +18,6 @@ import { reactNativeTracingIntegration } from '../../../src/js/tracing/reactnati 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; diff --git a/test/tracing/mockedrountinginstrumention.ts b/test/tracing/mockedrountinginstrumention.ts deleted file mode 100644 index 53f0d68f74..0000000000 --- a/test/tracing/mockedrountinginstrumention.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { RoutingInstrumentation } from '../../src/js'; -import type { OnConfirmRoute, TransactionCreator } from '../../src/js/tracing/routingInstrumentation'; -import type { BeforeNavigate } from '../../src/js/tracing/types'; - -export interface MockedRoutingInstrumentation extends RoutingInstrumentation { - registeredListener?: TransactionCreator; - registeredBeforeNavigate?: BeforeNavigate; - registeredOnConfirmRoute?: OnConfirmRoute; -} - -export const createMockedRoutingInstrumentation = (): MockedRoutingInstrumentation => { - const mock: MockedRoutingInstrumentation = { - name: 'TestRoutingInstrumentationInstance', - onRouteWillChange: jest.fn(), - registerRoutingInstrumentation: jest.fn( - (listener: TransactionCreator, beforeNavigate: BeforeNavigate, onConfirmRoute: OnConfirmRoute) => { - mock.registeredListener = listener; - mock.registeredBeforeNavigate = beforeNavigate; - mock.registeredOnConfirmRoute = onConfirmRoute; - }, - ), - }; - return mock; -}; diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 9b84f14b3d..0233c40ecc 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -10,8 +10,6 @@ jest.mock('@sentry/utils', () => { import * as SentryBrowser from '@sentry/browser'; import type { Event } from '@sentry/types'; -import { RoutingInstrumentation } from '../../src/js/tracing/routingInstrumentation'; - jest.mock('../../src/js/wrapper', () => { return { NATIVE: { From f3667a6667e56188400eaefcc00388c7acb4b9a4 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 9 Aug 2024 22:32:32 +0200 Subject: [PATCH 40/51] wip: fixing test --- test/tracing/reactnavigation.test.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index 7ce0afa599..f7a3412ff2 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -85,7 +85,7 @@ describe('ReactNavigationInstrumentation', () => { }); describe('initial navigation span is created after all integrations are setup', () => { - let rnTracing: ReturnType; + let reactNavigation: ReturnType; beforeEach(() => { const startFrames = { @@ -100,14 +100,10 @@ describe('ReactNavigationInstrumentation', () => { }; NATIVE.fetchNativeFrames.mockResolvedValueOnce(startFrames).mockResolvedValueOnce(finishFrames); - const rNavigation = new reactNavigationIntegration({ + reactNavigation = reactNavigationIntegration({ routeChangeTimeoutMs: 200, }); - mockNavigation = createMockNavigationAndAttachTo(rNavigation); - - rnTracing = reactNativeTracingIntegration({ - routingInstrumentation: rNavigation, - }); + mockNavigation = createMockNavigationAndAttachTo(reactNavigation); }); test('initial navigation span contains native frames when nativeFrames integration is after react native tracing', async () => { @@ -115,7 +111,7 @@ describe('ReactNavigationInstrumentation', () => { enableNativeFramesTracking: true, enableStallTracking: false, tracesSampleRate: 1.0, - integrations: [rnTracing, nativeFramesIntegration()], + integrations: [reactNavigation, nativeFramesIntegration()], enableAppStartTracking: false, }); client = new TestClient(options); @@ -134,7 +130,7 @@ describe('ReactNavigationInstrumentation', () => { enableNativeFramesTracking: true, enableStallTracking: false, tracesSampleRate: 1.0, - integrations: [nativeFramesIntegration(), rnTracing], + integrations: [nativeFramesIntegration(), reactNavigation], enableAppStartTracking: false, }); client = new TestClient(options); @@ -314,7 +310,7 @@ describe('ReactNavigationInstrumentation', () => { describe('navigation container registration', () => { test('registers navigation container object ref', () => { - const instrumentation = new reactNavigationIntegration(); + const instrumentation = reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer({ current: mockNavigationContainer, @@ -327,7 +323,7 @@ describe('ReactNavigationInstrumentation', () => { }); test('registers navigation container direct ref', () => { - const instrumentation = new reactNavigationIntegration(); + const instrumentation = reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer(mockNavigationContainer); @@ -340,7 +336,7 @@ describe('ReactNavigationInstrumentation', () => { test('does not register navigation container if there is an existing one', () => { RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; - const instrumentation = new reactNavigationIntegration(); + const instrumentation = reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer({ current: mockNavigationContainer, @@ -353,7 +349,7 @@ describe('ReactNavigationInstrumentation', () => { }); test('works if routing instrumentation registration is after navigation registration', async () => { - const instrumentation = new reactNavigationIntegration(); + const instrumentation = reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer(mockNavigationContainer); @@ -374,7 +370,7 @@ describe('ReactNavigationInstrumentation', () => { describe('options', () => { test('waits until routeChangeTimeoutMs', () => { - const instrumentation = new reactNavigationIntegration({ + const instrumentation = reactNavigationIntegration({ routeChangeTimeoutMs: 200, }); @@ -409,7 +405,7 @@ describe('ReactNavigationInstrumentation', () => { beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions; } = {}, ) { - const rNavigation = new reactNavigationIntegration({ + const rNavigation = reactNavigationIntegration({ routeChangeTimeoutMs: 200, }); mockNavigation = createMockNavigationAndAttachTo(rNavigation); From fc6d8f0c2bb05ca307659f123783c6c49ccdb27d Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Sat, 10 Aug 2024 23:53:44 +0200 Subject: [PATCH 41/51] continue fixing tests --- src/js/tracing/reactnavigation.ts | 29 +++- src/js/tracing/span.ts | 15 +- test/sdk.test.ts | 9 +- test/tracing/gesturetracing.test.ts | 8 +- test/tracing/idleNavigationSpan.test.ts | 82 +++++++++ test/tracing/reactnativetracing.test.ts | 187 +++------------------ test/tracing/reactnavigation.test.ts | 35 ++-- test/tracing/reactnavigation.ttid.test.tsx | 14 +- test/tracing/reactnavigationutils.ts | 2 +- 9 files changed, 166 insertions(+), 215 deletions(-) create mode 100644 test/tracing/idleNavigationSpan.test.ts diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index 8cda99ba7c..f09f6a7570 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -2,6 +2,7 @@ import { addBreadcrumb, getActiveSpan, + getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_OP, setMeasurement, SPAN_STATUS_OK, @@ -106,11 +107,13 @@ export const reactNavigationIntegration = ({ */ const afterAllSetup = (client: Client): void => { tracing = getReactNativeTracingIntegration(client); - idleSpanOptions = { - finalTimeout: tracing.options.finalTimeoutMs, - idleTimeout: tracing.options.idleTimeoutMs, - ignoreEmptyBackNavigationTransactions, - }; + if (tracing) { + idleSpanOptions = { + finalTimeout: tracing.options.finalTimeoutMs, + idleTimeout: tracing.options.idleTimeoutMs, + ignoreEmptyBackNavigationTransactions, + }; + } if (initialStateHandled) { // We create an initial state here to ensure a transaction gets created before the first route mounts. @@ -190,9 +193,19 @@ export const reactNavigationIntegration = ({ } latestTransaction = startGenericIdleNavigationSpan( - tracing.options.beforeStartSpan - ? tracing.options.beforeStartSpan({ name: DEFAULT_NAVIGATION_SPAN_NAME }) - : { name: DEFAULT_NAVIGATION_SPAN_NAME }, + tracing && tracing.options.beforeStartSpan + ? tracing.options.beforeStartSpan({ + name: DEFAULT_NAVIGATION_SPAN_NAME, + op: 'navigation', + forceTransaction: true, + scope: getCurrentScope(), + }) + : { + name: DEFAULT_NAVIGATION_SPAN_NAME, + op: 'navigation', + forceTransaction: true, + scope: getCurrentScope(), + }, idleSpanOptions, ); diff --git a/src/js/tracing/span.ts b/src/js/tracing/span.ts index f73707ccf5..4960b8609f 100644 --- a/src/js/tracing/span.ts +++ b/src/js/tracing/span.ts @@ -14,18 +14,19 @@ import { generatePropagationContext, logger } from '@sentry/utils'; import { isRootSpan } from '../utils/span'; import { adjustTransactionDuration, cancelInBackground, ignoreEmptyBackNavigation } from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_INTERACTION } from './origin'; +import { defaultReactNativeTracingOptions } from './reactnativetracing'; export const startIdleNavigationSpan = ( startSpanOption: StartSpanOptions, { - finalTimeout, - idleTimeout, - ignoreEmptyBackNavigationTransactions, + finalTimeout = defaultReactNativeTracingOptions.finalTimeoutMs, + idleTimeout = defaultReactNativeTracingOptions.idleTimeoutMs, + ignoreEmptyBackNavigationTransactions = true, }: { - finalTimeout: number; - idleTimeout: number; - ignoreEmptyBackNavigationTransactions: boolean; - }, + finalTimeout?: number; + idleTimeout?: number; + ignoreEmptyBackNavigationTransactions?: boolean; + } = {}, ): Span | undefined => { const client = getClient(); if (!client) { diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 1c56a9d465..1e89412e45 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -13,11 +13,7 @@ import { logger } from '@sentry/utils'; import { init, withScope } from '../src/js/sdk'; import type { ReactNativeTracingIntegration } from '../src/js/tracing'; -import { - REACT_NATIVE_TRACING_INTEGRATION_NAME, - reactNativeTracingIntegration, - ReactNavigationInstrumentation, -} from '../src/js/tracing'; +import { REACT_NATIVE_TRACING_INTEGRATION_NAME, reactNativeTracingIntegration } from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environment'; import { NATIVE } from './mockWrapper'; @@ -36,8 +32,7 @@ describe('Tests the SDK functionality', () => { describe('init', () => { describe('enableAutoPerformanceTracing', () => { const reactNavigationInstrumentation = (): ReactNativeTracingIntegration => { - const nav = new ReactNavigationInstrumentation(); - return reactNativeTracingIntegration({ routingInstrumentation: nav }); + return reactNativeTracingIntegration(); }; it('Auto Performance is disabled by default', () => { diff --git a/test/tracing/gesturetracing.test.ts b/test/tracing/gesturetracing.test.ts index 49dc14c69a..5151466154 100644 --- a/test/tracing/gesturetracing.test.ts +++ b/test/tracing/gesturetracing.test.ts @@ -49,7 +49,6 @@ describe('GestureTracing', () => { describe('traces gestures', () => { let client: TestClient; let tracing: ReactNativeTracingIntegration; - let mockedRoutingInstrumentation: MockedRoutingInstrumentation; let mockedGesture: MockGesture; beforeEach(() => { @@ -58,12 +57,9 @@ describe('GestureTracing', () => { client = setupTestClient({ enableUserInteractionTracing: true, }); - mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); - tracing = reactNativeTracingIntegration({ - routingInstrumentation: mockedRoutingInstrumentation, - }); + tracing = reactNativeTracingIntegration(); client.addIntegration(tracing); - mockedRoutingInstrumentation.registeredOnConfirmRoute!('mockedScreenName'); + tracing.setCurrentRoute('mockedScreenName'); mockedGesture = { handlers: { onBegin: jest.fn(), diff --git a/test/tracing/idleNavigationSpan.test.ts b/test/tracing/idleNavigationSpan.test.ts new file mode 100644 index 0000000000..0d00907fad --- /dev/null +++ b/test/tracing/idleNavigationSpan.test.ts @@ -0,0 +1,82 @@ +import { getActiveSpan, spanToJSON } from '@sentry/core'; +import type { AppState, AppStateStatus } from 'react-native'; + +import { startIdleNavigationSpan } from '../../src/js/tracing/span'; +import { NATIVE } from '../../src/js/wrapper'; +import { setupTestClient } from '../mocks/client'; + +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); + +describe('startIdleNavigationSpan', () => { + beforeEach(() => { + jest.useFakeTimers(); + NATIVE.enableNative = true; + mockedAppState.isAvailable = true; + mockedAppState.addEventListener = (_, listener) => { + mockedAppState.listener = listener; + return { + remove: mockedAppState.removeSubscription, + }; + }; + setupTestClient(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('Cancels route transaction when app goes to background', async () => { + const routeTransaction = startIdleNavigationSpan({ + name: 'test', + }); + + mockedAppState.setState('background'); + + jest.runAllTimers(); + + expect(routeTransaction).toBeDefined(); + expect(spanToJSON(routeTransaction!).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 + + startIdleNavigationSpan({ + name: 'test', + }); + + await jest.advanceTimersByTimeAsync(500); + const transaction = getActiveSpan(); + + jest.runAllTimers(); + + expect(spanToJSON(transaction!).timestamp).toBeDefined(); + }); +}); diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 0233c40ecc..80ee392ed5 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -31,48 +31,16 @@ jest.mock('../../src/js/tracing/utils', () => { }; }); -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); - -import { getActiveSpan, spanToJSON } from '@sentry/browser'; -import type { AppState, AppStateStatus } from 'react-native'; - import { reactNativeTracingIntegration } from '../../src/js/tracing/reactnativetracing'; -import { NATIVE } from '../../src/js/wrapper'; import type { TestClient } from '../mocks/client'; import { setupTestClient } from '../mocks/client'; describe('ReactNativeTracing', () => { + let client: TestClient; + beforeEach(() => { jest.useFakeTimers(); - NATIVE.enableNative = true; - mockedAppState.isAvailable = true; - mockedAppState.addEventListener = (_, listener) => { - mockedAppState.listener = listener; - return { - remove: mockedAppState.removeSubscription, - }; - }; + client = setupTestClient(); }); afterEach(() => { @@ -112,143 +80,42 @@ describe('ReactNativeTracing', () => { }); }); - describe('Tracing Instrumentation', () => { - let client: TestClient; - - beforeEach(() => { - client = setupTestClient(); - }); - - describe('With routing instrumentation', () => { - it('Cancels route transaction when app goes to background', async () => { - const routingInstrumentation = new RoutingInstrumentation(); - const integration = reactNativeTracingIntegration({ - routingInstrumentation, - }); - - integration.setup(client); - integration.afterAllSetup(client); - // wait for internal promises to resolve, fetch app start data from mocked native - await Promise.resolve(); - - const routeTransaction = routingInstrumentation.onRouteWillChange({ - name: 'test', - }); - - mockedAppState.setState('background'); + describe('View Names event processor', () => { + it('Do not overwrite event app context', () => { + const integration = reactNativeTracingIntegration(); - jest.runAllTimers(); + const expectedRouteName = 'Route'; + const event: Event = { contexts: { app: { appKey: 'value' } } }; + const expectedEvent: Event = { contexts: { app: { appKey: 'value', view_names: [expectedRouteName] } } }; - expect(routeTransaction).toBeDefined(); - expect(spanToJSON(routeTransaction!).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 - - const routingInstrumentation = new RoutingInstrumentation(); - setupTestClient({ - integrations: [ - reactNativeTracingIntegration({ - routingInstrumentation, - }), - ], - }); - - routingInstrumentation.onRouteWillChange({ - name: 'test', - }); + integration.setCurrentRoute(expectedRouteName); + const processedEvent = integration.processEvent(event, {}, client); - await jest.advanceTimersByTimeAsync(500); - const transaction = getActiveSpan(); - - jest.runAllTimers(); - - expect(spanToJSON(transaction!).timestamp).toBeDefined(); - }); + expect(processedEvent).toEqual(expectedEvent); }); - }); - describe('Routing Instrumentation', () => { - let client: TestClient; + it('Do not add view_names if context is undefined', () => { + const integration = reactNativeTracingIntegration(); - beforeEach(() => { - client = setupTestClient(); - }); - - describe('_onConfirmRoute', () => { - it('Sets app context', async () => { - const routing = new RoutingInstrumentation(); - const integration = reactNativeTracingIntegration({ - routingInstrumentation: routing, - }); - - client.addIntegration(integration); - - routing.onRouteWillChange({ name: 'First Route' }); - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - routing.onRouteWillChange({ name: 'Second Route' }); - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - const transaction = client.event; - expect(transaction!.contexts!.app).toBeDefined(); - expect(transaction!.contexts!.app!['view_names']).toEqual(['Second Route']); - }); - - describe('View Names event processor', () => { - it('Do not overwrite event app context', () => { - const routing = new RoutingInstrumentation(); - const integration = reactNativeTracingIntegration({ - routingInstrumentation: routing, - }); + const expectedRouteName = 'Route'; + const event: Event = { release: 'value' }; + const expectedEvent: Event = { release: 'value' }; - const expectedRouteName = 'Route'; - const event: Event = { contexts: { app: { appKey: 'value' } } }; - const expectedEvent: Event = { contexts: { app: { appKey: 'value', view_names: [expectedRouteName] } } }; + integration.setCurrentRoute(expectedRouteName); + const processedEvent = integration.processEvent(event, {}, client); - 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 = reactNativeTracingIntegration({ - routingInstrumentation: routing, - }); - - const expectedRouteName = 'Route'; - const event: Event = { release: 'value' }; - const expectedEvent: Event = { release: 'value' }; - - integration.state.currentRoute = expectedRouteName; - const processedEvent = integration.processEvent(event, {}, client); - - expect(processedEvent).toEqual(expectedEvent); - }); + expect(processedEvent).toEqual(expectedEvent); + }); - it('ignore view_names if undefined', () => { - const routing = new RoutingInstrumentation(); - const integration = reactNativeTracingIntegration({ - routingInstrumentation: routing, - }); + it('ignore view_names if undefined', () => { + const integration = reactNativeTracingIntegration(); - const event: Event = { contexts: { app: { key: 'value ' } } }; - const expectedEvent: Event = { contexts: { app: { key: 'value ' } } }; + const event: Event = { contexts: { app: { key: 'value ' } } }; + const expectedEvent: Event = { contexts: { app: { key: 'value ' } } }; - const processedEvent = integration.processEvent(event, {}, client); + const processedEvent = integration.processEvent(event, {}, client); - expect(processedEvent).toEqual(expectedEvent); - }); - }); + expect(processedEvent).toEqual(expectedEvent); }); }); }); diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index f7a3412ff2..836fa2c560 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -1,6 +1,14 @@ /* eslint-disable deprecation/deprecation */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { getCurrentScope, getGlobalScope, getIsolationScope, SentrySpan, setCurrentClient } from '@sentry/core'; +import type { + SentrySpan} from '@sentry/core'; +import { + getActiveSpan, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCurrentClient, +} from '@sentry/core'; import type { Event, Measurements, StartSpanOptions } from '@sentry/types'; import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js'; @@ -348,19 +356,14 @@ describe('ReactNavigationInstrumentation', () => { expect(mockNavigationContainer.addListener).not.toHaveBeenCalled(); }); - test('works if routing instrumentation registration is after navigation registration', async () => { + test('works if routing instrumentation setup is after navigation registration', async () => { const instrumentation = reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer(mockNavigationContainer); - const mockTransaction = new SentrySpan(); - const tracingListener = jest.fn(() => mockTransaction); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - () => {}, - ); + instrumentation.afterAllSetup(client); + const mockTransaction = getActiveSpan() as SentrySpan; await jest.runOnlyPendingTimersAsync(); @@ -374,13 +377,7 @@ describe('ReactNavigationInstrumentation', () => { routeChangeTimeoutMs: 200, }); - const mockTransaction = new SentrySpan({ sampled: true, name: DEFAULT_NAVIGATION_SPAN_NAME }); - const tracingListener = jest.fn(() => mockTransaction); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - () => {}, - ); + instrumentation.afterAllSetup(client); const mockNavigationContainerRef = { current: new MockNavigationContainer(), @@ -388,11 +385,12 @@ describe('ReactNavigationInstrumentation', () => { instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); + const mockTransaction = getActiveSpan() as SentrySpan; jest.advanceTimersByTime(190); expect(mockTransaction['_sampled']).toBe(true); - expect(mockTransaction['_name']).toBe('Route'); + expect(mockTransaction['_name']).toBe(DEFAULT_NAVIGATION_SPAN_NAME); jest.advanceTimersByTime(20); @@ -411,7 +409,6 @@ describe('ReactNavigationInstrumentation', () => { mockNavigation = createMockNavigationAndAttachTo(rNavigation); const rnTracing = reactNativeTracingIntegration({ - routingInstrumentation: rNavigation, beforeStartSpan: setupOptions.beforeSpanStart, }); @@ -419,7 +416,7 @@ describe('ReactNavigationInstrumentation', () => { enableNativeFramesTracking: false, enableStallTracking: false, tracesSampleRate: 1.0, - integrations: [rnTracing], + integrations: [rNavigation, rnTracing], enableAppStartTracking: false, }); client = new TestClient(options); diff --git a/test/tracing/reactnavigation.ttid.test.tsx b/test/tracing/reactnavigation.ttid.test.tsx index e6743fc7d5..5088e4e5da 100644 --- a/test/tracing/reactnavigation.ttid.test.tsx +++ b/test/tracing/reactnavigation.ttid.test.tsx @@ -12,7 +12,6 @@ import React from "react"; 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'; @@ -513,7 +512,10 @@ describe('React Navigation - TTID', () => { } function createTestedInstrumentation(options?: { enableTimeToInitialDisplay?: boolean }) { - const sut = new ReactNavigationInstrumentation(options); + const sut = Sentry.reactNavigationIntegration({ + ...options, + ignoreEmptyBackNavigationTransactions: true, // default true + }); return sut; } @@ -523,7 +525,7 @@ describe('React Navigation - TTID', () => { } }); -function initSentry(sut: ReactNavigationInstrumentation): { +function initSentry(sut: ReturnType): { transportSendMock: jest.Mock, Parameters>; } { RN_GLOBAL_OBJ.__sentry_rn_v5_registered = false; @@ -533,10 +535,8 @@ function initSentry(sut: ReactNavigationInstrumentation): { enableTracing: true, enableStallTracking: false, integrations: [ - Sentry.reactNativeTracingIntegration({ - routingInstrumentation: sut, - ignoreEmptyBackNavigationTransactions: true, // default true - }), + sut, + Sentry.reactNativeTracingIntegration(), ], transport: () => ({ send: transportSendMock.mockResolvedValue({}), diff --git a/test/tracing/reactnavigationutils.ts b/test/tracing/reactnavigationutils.ts index 7c15b6acac..3aba609d13 100644 --- a/test/tracing/reactnavigationutils.ts +++ b/test/tracing/reactnavigationutils.ts @@ -1,6 +1,6 @@ import type { NavigationRoute, reactNavigationIntegration } from '../../src/js/tracing/reactnavigation'; -export function createMockNavigationAndAttachTo(sut: reactNavigationIntegration) { +export function createMockNavigationAndAttachTo(sut: ReturnType) { const mockedNavigationContained = mockNavigationContainer(); const mockedNavigation = { emitCancelledNavigation: () => { From 039ac426f48d6215cacead3e8267eb66d1ae9397 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Sun, 11 Aug 2024 12:46:22 +0200 Subject: [PATCH 42/51] fix react navigation tests --- src/js/tracing/reactnavigation.ts | 17 +++---------- src/js/tracing/span.ts | 25 ++++++++++++++++--- test/client.test.ts | 25 ------------------- .../integrations/userInteraction.test.ts | 13 +++------- .../reactnavigation.stalltracking.test.ts | 8 +++--- test/tracing/reactnavigation.test.ts | 11 ++------ 6 files changed, 33 insertions(+), 66 deletions(-) diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index f09f6a7570..bbd0e87ea2 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -2,7 +2,6 @@ import { addBreadcrumb, getActiveSpan, - getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_OP, setMeasurement, SPAN_STATUS_OK, @@ -24,7 +23,7 @@ import { getReactNativeTracingIntegration, } from './reactnativetracing'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; -import { startIdleNavigationSpan as startGenericIdleNavigationSpan } from './span'; +import { getDefaultIdleNavigationSpanOptions, startIdleNavigationSpan as startGenericIdleNavigationSpan } from './span'; import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan } from './timetodisplay'; export const INTEGRATION_NAME = 'ReactNavigation'; @@ -194,18 +193,8 @@ export const reactNavigationIntegration = ({ latestTransaction = startGenericIdleNavigationSpan( tracing && tracing.options.beforeStartSpan - ? tracing.options.beforeStartSpan({ - name: DEFAULT_NAVIGATION_SPAN_NAME, - op: 'navigation', - forceTransaction: true, - scope: getCurrentScope(), - }) - : { - name: DEFAULT_NAVIGATION_SPAN_NAME, - op: 'navigation', - forceTransaction: true, - scope: getCurrentScope(), - }, + ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) + : getDefaultIdleNavigationSpanOptions(), idleSpanOptions, ); diff --git a/src/js/tracing/span.ts b/src/js/tracing/span.ts index 4960b8609f..078f690dae 100644 --- a/src/js/tracing/span.ts +++ b/src/js/tracing/span.ts @@ -14,7 +14,7 @@ import { generatePropagationContext, logger } from '@sentry/utils'; import { isRootSpan } from '../utils/span'; import { adjustTransactionDuration, cancelInBackground, ignoreEmptyBackNavigation } from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_INTERACTION } from './origin'; -import { defaultReactNativeTracingOptions } from './reactnativetracing'; +import { DEFAULT_NAVIGATION_SPAN_NAME, defaultReactNativeTracingOptions } from './reactnativetracing'; export const startIdleNavigationSpan = ( startSpanOption: StartSpanOptions, @@ -43,10 +43,15 @@ export const startIdleNavigationSpan = ( activeSpan.end(); } - const idleSpan = startIdleSpan(startSpanOption, { finalTimeout, idleTimeout }); + const finalStartStapOptions = { + ...getDefaultIdleNavigationSpanOptions(), + ...startSpanOption, + }; + + const idleSpan = startIdleSpan(finalStartStapOptions, { finalTimeout, idleTimeout }); logger.log( - `[ReactNativeTracing] Starting ${startSpanOption.op || 'unknown op'} transaction "${ - startSpanOption.name + `[ReactNativeTracing] Starting ${finalStartStapOptions.op || 'unknown op'} transaction "${ + finalStartStapOptions.name }" on scope`, ); @@ -82,6 +87,18 @@ export const startIdleSpan = ( return span; }; +/** + * Returns the default options for the idle navigation span. + */ +export function getDefaultIdleNavigationSpanOptions(): StartSpanOptions { + return { + name: DEFAULT_NAVIGATION_SPAN_NAME, + op: 'navigation', + forceTransaction: true, + scope: getCurrentScope(), + }; +} + /** * Checks if the span is a Sentry User Interaction span. */ diff --git a/test/client.test.ts b/test/client.test.ts index a4e5d277de..7635b54932 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -8,8 +8,6 @@ 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 { 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'; @@ -608,29 +606,6 @@ describe('Tests ReactNativeClient', () => { client.recordDroppedEvent('before_send', 'error'); } }); - - describe('register enabled instrumentation as integrations', () => { - test('register routing instrumentation', () => { - const mockRoutingInstrumentation: RoutingInstrumentationInstance = { - registerRoutingInstrumentation: jest.fn(), - onRouteWillChange: jest.fn(), - name: 'MockRoutingInstrumentation', - }; - const client = new ReactNativeClient( - mockedOptions({ - dsn: EXAMPLE_DSN, - integrations: [ - reactNativeTracingIntegration({ - routingInstrumentation: mockRoutingInstrumentation, - }), - ], - }), - ); - client.init(); - - expect(client.getIntegrationByName('MockRoutingInstrumentation')).toBeTruthy(); - }); - }); }); function mockedOptions(options: Partial): ReactNativeClientOptions { diff --git a/test/tracing/integrations/userInteraction.test.ts b/test/tracing/integrations/userInteraction.test.ts index 94b6ae31a1..3ec8905a38 100644 --- a/test/tracing/integrations/userInteraction.test.ts +++ b/test/tracing/integrations/userInteraction.test.ts @@ -15,6 +15,7 @@ import { } from '../../../src/js/tracing/integrations/userInteraction'; import type { ReactNativeTracingIntegration } from '../../../src/js/tracing/reactnativetracing'; import { reactNativeTracingIntegration } from '../../../src/js/tracing/reactnativetracing'; +import { startIdleNavigationSpan } from '../../../src/js/tracing/span'; import { NATIVE } from '../../../src/js/wrapper'; import type { TestClient } from '../../mocks/client'; import { setupTestClient } from '../../mocks/client'; @@ -58,7 +59,6 @@ describe('User Interaction Tracing', () => { let client: TestClient; let tracing: ReactNativeTracingIntegration; let mockedUserInteractionId: { elementId: string | undefined; op: string }; - let mockedRoutingInstrumentation: MockedRoutingInstrumentation; beforeEach(() => { jest.useFakeTimers(); @@ -75,7 +75,6 @@ describe('User Interaction Tracing', () => { client = setupTestClient({ enableUserInteractionTracing: true, }); - mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); }); afterEach(() => { @@ -87,7 +86,6 @@ describe('User Interaction Tracing', () => { describe('disabled user interaction', () => { test('User interaction tracing is disabled by default', () => { client = setupTestClient({}); - mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); startUserInteractionSpan(mockedUserInteractionId); expect(client.getOptions().enableUserInteractionTracing).toBeFalsy(); @@ -97,12 +95,10 @@ describe('User Interaction Tracing', () => { describe('enabled user interaction', () => { beforeEach(() => { - tracing = reactNativeTracingIntegration({ - routingInstrumentation: mockedRoutingInstrumentation, - }); + tracing = reactNativeTracingIntegration(); client.addIntegration(userInteractionIntegration()); client.addIntegration(tracing); - mockedRoutingInstrumentation.registeredOnConfirmRoute!('mockedRouteName'); + tracing.setCurrentRoute('mockedRouteName'); }); test('user interaction tracing is enabled and transaction is bound to scope', () => { @@ -270,8 +266,7 @@ describe('User Interaction Tracing', () => { startUserInteractionSpan(mockedUserInteractionId); const interactionTransaction = getActiveSpan(); jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - - const routingTransaction = mockedRoutingInstrumentation.registeredListener!({ + const routingTransaction = startIdleNavigationSpan({ name: 'newMockedRouteName', }); jest.runAllTimers(); diff --git a/test/tracing/reactnavigation.stalltracking.test.ts b/test/tracing/reactnavigation.stalltracking.test.ts index b3548a98ab..0fbc3b8627 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 { reactNativeTracingIntegration, ReactNavigationInstrumentation } from '../../src/js'; +import { reactNativeTracingIntegration, reactNavigationIntegration } 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'; @@ -26,12 +26,10 @@ describe('StallTracking with ReactNavigation', () => { getIsolationScope().clear(); getGlobalScope().clear(); - const rnavigation = new ReactNavigationInstrumentation(); + const rnavigation = reactNavigationIntegration(); mockNavigation = createMockNavigationAndAttachTo(rnavigation); - const rnTracing = reactNativeTracingIntegration({ - routingInstrumentation: rnavigation, - }); + const rnTracing = reactNativeTracingIntegration(); const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index 836fa2c560..142d58b75a 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -1,14 +1,7 @@ /* eslint-disable deprecation/deprecation */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { - SentrySpan} from '@sentry/core'; -import { - getActiveSpan, - getCurrentScope, - getGlobalScope, - getIsolationScope, - setCurrentClient, -} from '@sentry/core'; +import type { SentrySpan } from '@sentry/core'; +import { getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient } from '@sentry/core'; import type { Event, Measurements, StartSpanOptions } from '@sentry/types'; import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js'; From cee6c87793f6b1c3fe021207a4cbf2f298d3a0b8 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Sun, 11 Aug 2024 19:22:32 +0200 Subject: [PATCH 43/51] update react native navigation to the new func style --- src/js/tracing/reactnativenavigation.ts | 222 ++++++++++++--------- src/js/tracing/reactnavigation.ts | 51 +++-- test/tracing/reactnativenavigation.test.ts | 17 +- 3 files changed, 155 insertions(+), 135 deletions(-) diff --git a/src/js/tracing/reactnativenavigation.ts b/src/js/tracing/reactnativenavigation.ts index 1ec729f00f..decdde3f7f 100644 --- a/src/js/tracing/reactnativenavigation.ts +++ b/src/js/tracing/reactnativenavigation.ts @@ -4,34 +4,55 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, } from '@sentry/core'; -import type { Span } from '@sentry/types'; +import type { Client, Integration, Span } from '@sentry/types'; import type { EmitterSubscription } from '../utils/rnlibrariesinterface'; import { isSentrySpan } from '../utils/span'; -import { DEFAULT_NAVIGATION_SPAN_NAME } from './reactnativetracing'; +import type { ReactNativeTracingIntegration } from './reactnativetracing'; +import { + DEFAULT_NAVIGATION_SPAN_NAME, + defaultReactNativeTracingOptions, + getReactNativeTracingIntegration, +} from './reactnativetracing'; +import { getDefaultIdleNavigationSpanOptions, startIdleNavigationSpan as startGenericIdleNavigationSpan } from './span'; + +export const INTEGRATION_NAME = 'ReactNativeNavigation'; + +const NAVIGATION_HISTORY_MAX_SIZE = 200; interface ReactNativeNavigationOptions { /** * How long the instrumentation will wait for the route to mount after a change has been initiated, * before the transaction is discarded. - * Time is in ms. * - * Default: 1000 + * @default 1_000 (ms) */ - routeChangeTimeoutMs: number; + routeChangeTimeoutMs?: number; + /** * Instrumentation will create a transaction on tab change. * By default only navigation commands create transactions. * - * Default: true + * @default false */ - enableTabsInstrumentation: boolean; -} + enableTabsInstrumentation?: boolean; -const defaultOptions: ReactNativeNavigationOptions = { - routeChangeTimeoutMs: 1000, - enableTabsInstrumentation: true, -}; + /** + * 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 + */ + ignoreEmptyBackNavigationTransactions?: boolean; + + /** The React Native Navigation `NavigationDelegate`. + * + * ```js + * import { Navigation } from 'react-native-navigation'; + * ``` + */ + navigation: unknown; +} interface ComponentEvent { componentId: string; @@ -71,134 +92,137 @@ export interface NavigationDelegate { * - `_onComponentWillAppear` is then called AFTER the state change happens due to a dispatch and sets the route context onto the active transaction. * - If `_onComponentWillAppear` isn't called within `options.routeChangeTimeoutMs` of the dispatch, then the transaction is not sampled and finished. */ -export class ReactNativeNavigationInstrumentation { - public static instrumentationName: string = 'react-native-navigation'; - - public readonly name: string = ReactNativeNavigationInstrumentation.instrumentationName; - - private _navigation: NavigationDelegate; - private _options: ReactNativeNavigationOptions; - - private _prevComponentEvent: ComponentWillAppearEvent | null = null; - - private _latestTransaction?: Span; - private _recentComponentIds: string[] = []; - private _stateChangeTimeout?: number | undefined; - - public constructor( - /** The react native navigation `NavigationDelegate`. This is usually the import named `Navigation`. */ - navigation: unknown, - options: Partial = {}, - ) { - this._navigation = navigation as NavigationDelegate; - - this._options = { - ...defaultOptions, - ...options, - }; - } - - /** - * Registers the event listeners for React Native Navigation - */ - public registerRoutingInstrumentation(): void { - this._navigation.events().registerCommandListener(this._onNavigation.bind(this)); - - if (this._options.enableTabsInstrumentation) { - this._navigation.events().registerBottomTabPressedListener(this._onNavigation.bind(this)); +export const reactNativeNavigationIntegration = ({ + navigation: optionsNavigation, + routeChangeTimeoutMs = 1_000, + enableTabsInstrumentation = false, + ignoreEmptyBackNavigationTransactions = true, +}: ReactNativeNavigationOptions): Integration => { + const navigation = optionsNavigation as NavigationDelegate; + let recentComponentIds: string[] = []; + + let tracing: ReactNativeTracingIntegration | undefined; + let idleSpanOptions: Parameters[1] = { + finalTimeout: defaultReactNativeTracingOptions.finalTimeoutMs, + idleTimeout: defaultReactNativeTracingOptions.idleTimeoutMs, + ignoreEmptyBackNavigationTransactions, + }; + + let stateChangeTimeout: ReturnType | undefined; + let prevComponentEvent: ComponentWillAppearEvent | null = null; + let latestNavigationSpan: Span | undefined; + + const afterAllSetup = (client: Client): void => { + tracing = getReactNativeTracingIntegration(client); + if (tracing) { + idleSpanOptions = { + finalTimeout: tracing.options.finalTimeoutMs, + idleTimeout: tracing.options.idleTimeoutMs, + ignoreEmptyBackNavigationTransactions, + }; } + }; - this._navigation.events().registerComponentWillAppearListener(this._onComponentWillAppear.bind(this)); - } - - /** - * To be called when a navigation is initiated. (Command, BottomTabSelected, etc.) - */ - private _onNavigation(): void { - if (this._latestTransaction) { - this._discardLatestTransaction(); + const startIdleNavigationSpan = (): void => { + if (latestNavigationSpan) { + discardLatestNavigationSpan(); } - // this._latestTransaction = this.onRouteWillChange({ name: DEFAULT_NAVIGATION_SPAN_NAME }); - - this._stateChangeTimeout = setTimeout( - this._discardLatestTransaction.bind(this), - this._options.routeChangeTimeoutMs, + latestNavigationSpan = startGenericIdleNavigationSpan( + tracing && tracing.options.beforeStartSpan + ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) + : getDefaultIdleNavigationSpanOptions(), + idleSpanOptions, ); - } - /** - * To be called AFTER the state has been changed to populate the transaction with the current route. - */ - private _onComponentWillAppear(event: ComponentWillAppearEvent): void { - if (!this._latestTransaction) { + stateChangeTimeout = setTimeout(discardLatestNavigationSpan.bind(this), routeChangeTimeoutMs); + }; + + const updateLatestNavigationSpanWithCurrentComponent = (event: ComponentWillAppearEvent): void => { + if (!latestNavigationSpan) { return; } // We ignore actions that pertain to the same screen. - const isSameComponent = this._prevComponentEvent && event.componentId === this._prevComponentEvent.componentId; + const isSameComponent = prevComponentEvent && event.componentId === prevComponentEvent.componentId; if (isSameComponent) { - this._discardLatestTransaction(); + discardLatestNavigationSpan(); return; } - this._clearStateChangeTimeout(); + clearStateChangeTimeout(); - const routeHasBeenSeen = this._recentComponentIds.includes(event.componentId); + const routeHasBeenSeen = recentComponentIds.includes(event.componentId); - if (spanToJSON(this._latestTransaction).description === DEFAULT_NAVIGATION_SPAN_NAME) { - this._latestTransaction.updateName(event.componentName); + if (spanToJSON(latestNavigationSpan).description === DEFAULT_NAVIGATION_SPAN_NAME) { + latestNavigationSpan.updateName(event.componentName); } - this._latestTransaction.setAttributes({ + latestNavigationSpan.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, 'route.component_id': event.componentId, 'route.component_type': event.componentType, 'route.has_been_seen': routeHasBeenSeen, - 'previous_route.name': this._prevComponentEvent?.componentName, - 'previous_route.component_id': this._prevComponentEvent?.componentId, - 'previous_route.component_type': this._prevComponentEvent?.componentType, + 'previous_route.name': prevComponentEvent?.componentName, + 'previous_route.component_id': prevComponentEvent?.componentId, + 'previous_route.component_type': prevComponentEvent?.componentType, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }); - // this._beforeNavigate?.(this._latestTransaction); - - // this._onConfirmRoute?.(event.componentName); + tracing?.setCurrentRoute(event.componentName); addBreadcrumb({ category: 'navigation', type: 'navigation', message: `Navigation to ${event.componentName}`, data: { - from: this._prevComponentEvent?.componentName, + from: prevComponentEvent?.componentName, to: event.componentName, }, }); - this._prevComponentEvent = event; - this._latestTransaction = undefined; + pushRecentComponentId(event.componentId); + prevComponentEvent = event; + latestNavigationSpan = undefined; + }; + + navigation.events().registerCommandListener(startIdleNavigationSpan); + if (enableTabsInstrumentation) { + navigation.events().registerBottomTabPressedListener(startIdleNavigationSpan); } + navigation.events().registerComponentWillAppearListener(updateLatestNavigationSpanWithCurrentComponent); + + const pushRecentComponentId = (id: string): void => { + recentComponentIds.push(id); + + if (recentComponentIds.length > NAVIGATION_HISTORY_MAX_SIZE) { + recentComponentIds = recentComponentIds.slice(recentComponentIds.length - NAVIGATION_HISTORY_MAX_SIZE); + } + }; - /** Cancels the latest transaction so it does not get sent to Sentry. */ - private _discardLatestTransaction(): void { - if (this._latestTransaction) { - if (isSentrySpan(this._latestTransaction)) { - this._latestTransaction['_sampled'] = false; + const discardLatestNavigationSpan = (): void => { + if (latestNavigationSpan) { + if (isSentrySpan(latestNavigationSpan)) { + latestNavigationSpan['_sampled'] = false; } // TODO: What if it's not SentrySpan? - this._latestTransaction.end(); - this._latestTransaction = undefined; + latestNavigationSpan.end(); + latestNavigationSpan = undefined; } - this._clearStateChangeTimeout(); - } + clearStateChangeTimeout(); + }; - /** Cancels the latest transaction so it does not get sent to Sentry. */ - private _clearStateChangeTimeout(): void { - if (typeof this._stateChangeTimeout !== 'undefined') { - clearTimeout(this._stateChangeTimeout); - this._stateChangeTimeout = undefined; + const clearStateChangeTimeout = (): void => { + if (typeof stateChangeTimeout !== 'undefined') { + clearTimeout(stateChangeTimeout); + stateChangeTimeout = undefined; } - } -} + }; + + return { + name: INTEGRATION_NAME, + afterAllSetup, + }; +}; diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index bbd0e87ea2..fdbe475c39 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -86,7 +86,7 @@ export const reactNavigationIntegration = ({ }; let latestRoute: NavigationRoute | undefined; - let latestTransaction: Span | undefined; + let latestNavigationSpan: Span | undefined; let navigationProcessingSpan: Span | undefined; let initialStateHandled: boolean = false; @@ -119,7 +119,7 @@ export const reactNavigationIntegration = ({ return undefined; } - _startIdleNavigationSpan(); + startIdleNavigationSpan(); if (!navigationContainer) { // This is expected as navigation container is registered after the root component is mounted. @@ -155,7 +155,7 @@ export const reactNavigationIntegration = ({ } // This action is emitted on every dispatch - navigationContainer.addListener('__unsafe_action__', _startIdleNavigationSpan); + navigationContainer.addListener('__unsafe_action__', startIdleNavigationSpan); navigationContainer.addListener('state', updateLatestNavigationSpanWithCurrentRoute); RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; @@ -163,7 +163,7 @@ export const reactNavigationIntegration = ({ return undefined; } - if (!latestTransaction) { + if (!latestNavigationSpan) { logger.log( '[ReactNavigationInstrumentation] Navigation container registered, but integration has not been setup yet.', ); @@ -182,16 +182,16 @@ export const reactNavigationIntegration = ({ * It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change * and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute */ - const _startIdleNavigationSpan = (): void => { - if (latestTransaction) { + const startIdleNavigationSpan = (): void => { + if (latestNavigationSpan) { logger.log( '[ReactNavigationInstrumentation] A transaction was detected that turned out to be a noop, discarding.', ); _discardLatestTransaction(); - _clearStateChangeTimeout(); + clearStateChangeTimeout(); } - latestTransaction = startGenericIdleNavigationSpan( + latestNavigationSpan = startGenericIdleNavigationSpan( tracing && tracing.options.beforeStartSpan ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) : getDefaultIdleNavigationSpanOptions(), @@ -202,7 +202,7 @@ export const reactNavigationIntegration = ({ navigationProcessingSpan = startInactiveSpan({ op: 'navigation.processing', name: 'Navigation processing', - startTime: latestTransaction && spanToJSON(latestTransaction).start_timestamp, + startTime: latestNavigationSpan && spanToJSON(latestNavigationSpan).start_timestamp, }); } @@ -227,7 +227,7 @@ export const reactNavigationIntegration = ({ return undefined; } - if (!latestTransaction) { + if (!latestNavigationSpan) { logger.debug( `[${INTEGRATION_NAME}] Navigation state changed, but navigation transaction was not started on dispatch.`, ); @@ -236,11 +236,11 @@ export const reactNavigationIntegration = ({ if (previousRoute && previousRoute.key === route.key) { logger.debug(`[${INTEGRATION_NAME}] Navigation state changed, but route is the same as previous.`); - _pushRecentRouteKey(route.key); + pushRecentRouteKey(route.key); latestRoute = route; // Clear the latest transaction as it has been handled. - latestTransaction = undefined; + latestNavigationSpan = undefined; return undefined; } @@ -295,10 +295,10 @@ export const reactNavigationIntegration = ({ navigationProcessingSpan?.end(stateChangedTimestamp); navigationProcessingSpan = undefined; - if (spanToJSON(latestTransaction).description === DEFAULT_NAVIGATION_SPAN_NAME) { - latestTransaction.updateName(route.name); + if (spanToJSON(latestNavigationSpan).description === DEFAULT_NAVIGATION_SPAN_NAME) { + latestNavigationSpan.updateName(route.name); } - latestTransaction.setAttributes({ + latestNavigationSpan.setAttributes({ 'route.name': route.name, 'route.key': route.key, // TODO: filter PII params instead of dropping them all @@ -313,9 +313,8 @@ export const reactNavigationIntegration = ({ }); // Clear the timeout so the transaction does not get cancelled. - _clearStateChangeTimeout(); + clearStateChangeTimeout(); - // TODO: Add test for addBreadcrumb addBreadcrumb({ category: 'navigation', type: 'navigation', @@ -328,14 +327,14 @@ export const reactNavigationIntegration = ({ tracing?.setCurrentRoute(route.key); - _pushRecentRouteKey(route.key); + pushRecentRouteKey(route.key); latestRoute = route; // Clear the latest transaction as it has been handled. - latestTransaction = undefined; + latestNavigationSpan = undefined; }; /** Pushes a recent route key, and removes earlier routes when there is greater than the max length */ - const _pushRecentRouteKey = (key: string): void => { + const pushRecentRouteKey = (key: string): void => { recentRouteKeys.push(key); if (recentRouteKeys.length > NAVIGATION_HISTORY_MAX_SIZE) { @@ -345,20 +344,20 @@ export const reactNavigationIntegration = ({ /** Cancels the latest transaction so it does not get sent to Sentry. */ const _discardLatestTransaction = (): void => { - if (latestTransaction) { - if (isSentrySpan(latestTransaction)) { - latestTransaction['_sampled'] = false; + if (latestNavigationSpan) { + if (isSentrySpan(latestNavigationSpan)) { + latestNavigationSpan['_sampled'] = false; } // TODO: What if it's not SentrySpan? - latestTransaction.end(); - latestTransaction = undefined; + latestNavigationSpan.end(); + latestNavigationSpan = undefined; } if (navigationProcessingSpan) { navigationProcessingSpan = undefined; } }; - const _clearStateChangeTimeout = (): void => { + const clearStateChangeTimeout = (): void => { if (typeof stateChangeTimeout !== 'undefined') { clearTimeout(stateChangeTimeout); stateChangeTimeout = undefined; diff --git a/test/tracing/reactnativenavigation.test.ts b/test/tracing/reactnativenavigation.test.ts index f07c7c5505..5e1b9563d6 100644 --- a/test/tracing/reactnativenavigation.test.ts +++ b/test/tracing/reactnativenavigation.test.ts @@ -16,7 +16,7 @@ import type { ComponentWillAppearEvent, EventsRegistry, } from '../../src/js/tracing/reactnativenavigation'; -import { ReactNativeNavigationInstrumentation } from '../../src/js/tracing/reactnativenavigation'; +import { reactNativeNavigationIntegration } from '../../src/js/tracing/reactnativenavigation'; import { SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_ID, SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_TYPE, @@ -356,20 +356,17 @@ describe('React Native Navigation Instrumentation', () => { } = {}, ) { createMockNavigation(); - const rNavigation = new ReactNativeNavigationInstrumentation( - { + const rNavigation = reactNativeNavigationIntegration({ + navigation: { events() { return mockEventsRegistry; }, }, - { - routeChangeTimeoutMs: 200, - enableTabsInstrumentation: setupOptions.enableTabsInstrumentation, - }, - ); + routeChangeTimeoutMs: 200, + enableTabsInstrumentation: setupOptions.enableTabsInstrumentation, + }); const rnTracing = reactNativeTracingIntegration({ - routingInstrumentation: rNavigation, beforeStartSpan: setupOptions.beforeStartSpan, }); @@ -377,7 +374,7 @@ describe('React Native Navigation Instrumentation', () => { tracesSampleRate: 1.0, enableStallTracking: false, enableNativeFramesTracking: false, - integrations: [rnTracing], + integrations: [rNavigation, rnTracing], enableAppStartTracking: false, }); client = new TestClient(options); From ca122acf3ebc13248857ccd81a9035bf018cd1fe Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 12 Aug 2024 09:23:57 +0200 Subject: [PATCH 44/51] feat(tests): Add tsc to tests --- jest.config.js | 16 ++++++++------ src/js/index.ts | 2 +- src/js/profiling/integration.ts | 2 +- src/js/tracing/index.ts | 2 +- test/client.test.ts | 5 ++--- test/mockConsole.ts | 2 ++ test/mockWrapper.ts | 5 +++++ test/sdk.test.ts | 16 +++++++------- test/sdk.withclient.test.ts | 4 ++-- test/testutils.ts | 12 ----------- .../stalltracking.iteration.test.ts | 21 +++++++++++++++---- test/vendor/base64-js/big-data.test.ts | 3 ++- test/vendor/buffer/utf8ToBytes.test.ts | 2 -- 13 files changed, 51 insertions(+), 41 deletions(-) diff --git a/jest.config.js b/jest.config.js index 902b0a443b..f851092fd3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,13 +4,17 @@ module.exports = { setupFilesAfterEnv: ['jest-extended/all', '/test/mockConsole.ts'], globals: { __DEV__: true, - 'ts-jest': { - tsConfig: './tsconfig.json', - diagnostics: false, - }, }, - moduleFileExtensions: ['ts', 'tsx', 'js'], + transform: { + '^.+\\.jsx$': 'babel-jest', + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + }, + ], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], testPathIgnorePatterns: ['/test/e2e/', '/test/tools/', '/test/react-native/versions'], - testEnvironment: 'node', testMatch: ['**/*.test.(ts|tsx)'], }; diff --git a/src/js/index.ts b/src/js/index.ts index 5244b7fbb7..3ad4c88226 100644 --- a/src/js/index.ts +++ b/src/js/index.ts @@ -60,7 +60,7 @@ export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; export { reactNativeTracingIntegration, reactNavigationIntegration, - ReactNativeNavigationInstrumentation, + reactNativeNavigationIntegration, sentryTraceGesture, TimeToInitialDisplay, TimeToFullDisplay, diff --git a/src/js/profiling/integration.ts b/src/js/profiling/integration.ts index 584b000410..0859b16971 100644 --- a/src/js/profiling/integration.ts +++ b/src/js/profiling/integration.ts @@ -36,7 +36,7 @@ export const hermesProfilingIntegration: IntegrationFn = () => { startTimestampNs: number; } | undefined; - let _currentProfileTimeout: number | undefined; + let _currentProfileTimeout: ReturnType | undefined; let isReady: boolean = false; const setupOnce = (): void => { diff --git a/src/js/tracing/index.ts b/src/js/tracing/index.ts index e0bf02d7fe..aef8169eb9 100644 --- a/src/js/tracing/index.ts +++ b/src/js/tracing/index.ts @@ -5,7 +5,7 @@ export { export type { ReactNativeTracingIntegration } from './reactnativetracing'; export { reactNavigationIntegration } from './reactnavigation'; -export { ReactNativeNavigationInstrumentation } from './reactnativenavigation'; +export { reactNativeNavigationIntegration } from './reactnativenavigation'; export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types'; diff --git a/test/client.test.ts b/test/client.test.ts index 7635b54932..69a7a2436b 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -2,7 +2,7 @@ import * as mockedtimetodisplaynative from './tracing/mockedtimetodisplaynative' jest.mock('../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); import { defaultStackParser } from '@sentry/browser'; -import type { Envelope, Event, Outcome, Transport } from '@sentry/types'; +import type { Envelope, Event, Outcome, Transport, TransportMakeRequestResponse } from '@sentry/types'; import { rejectedSyncPromise, SentryError } from '@sentry/utils'; import * as RN from 'react-native'; @@ -104,7 +104,6 @@ describe('Tests ReactNativeClient', () => { }); await expect(client.eventFromMessage('test')).resolves.toBeDefined(); - // @ts-expect-error: Is Mocked await expect(RN.LogBox.ignoreLogs).toBeCalled(); }); @@ -133,7 +132,7 @@ describe('Tests ReactNativeClient', () => { }); test('use custom transport function', async () => { - const mySend = (_request: Envelope) => Promise.resolve(); + const mySend = (_request: Envelope): Promise => Promise.resolve({}); const myFlush = (timeout?: number) => Promise.resolve(Boolean(timeout)); const myCustomTransportFn = (): Transport => ({ send: mySend, diff --git a/test/mockConsole.ts b/test/mockConsole.ts index ac2409d0e0..025071ce14 100644 --- a/test/mockConsole.ts +++ b/test/mockConsole.ts @@ -6,3 +6,5 @@ global.console = { warn: jest.fn(), error: jest.fn(), }; + +export {}; diff --git a/test/mockWrapper.ts b/test/mockWrapper.ts index 360211a40a..3cbb789ba7 100644 --- a/test/mockWrapper.ts +++ b/test/mockWrapper.ts @@ -53,6 +53,9 @@ const NATIVE: MockInterface = { fetchNativeStackFramesBy: jest.fn(), initNativeReactNavigationNewFrameTracking: jest.fn(), + + captureReplay: jest.fn(), + getCurrentReplayId: jest.fn(), }; NATIVE.isNativeAvailable.mockReturnValue(true); @@ -74,6 +77,8 @@ NATIVE.stopProfiling.mockReturnValue(null); NATIVE.fetchNativePackageName.mockReturnValue('mock-native-package-name'); NATIVE.fetchNativeStackFramesBy.mockReturnValue(null); NATIVE.initNativeReactNavigationNewFrameTracking.mockReturnValue(Promise.resolve()); +NATIVE.captureReplay.mockResolvedValue(null); +NATIVE.getCurrentReplayId.mockReturnValue(null); export const getRNSentryModule = jest.fn(); diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 1e89412e45..c0389097b8 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -1,11 +1,3 @@ -jest.spyOn(logger, 'error'); -jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); -jest.mock('../src/js/utils/environment'); -jest.mock('@sentry/core', () => ({ - ...jest.requireActual('@sentry/core'), - initAndBind: jest.fn(), -})); - import { initAndBind } from '@sentry/core'; import { makeFetchTransport } from '@sentry/react'; import type { BaseTransportOptions, ClientOptions, Integration, Scope } from '@sentry/types'; @@ -19,6 +11,14 @@ import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environ import { NATIVE } from './mockWrapper'; import { firstArg, secondArg } from './testutils'; +jest.spyOn(logger, 'error'); +jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); +jest.mock('../src/js/utils/environment'); +jest.mock('@sentry/core', () => ({ + ...jest.requireActual('@sentry/core'), + initAndBind: jest.fn(), +})); + describe('Tests the SDK functionality', () => { beforeEach(() => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => true); diff --git a/test/sdk.withclient.test.ts b/test/sdk.withclient.test.ts index 1ed8c1c309..1e3cb131a1 100644 --- a/test/sdk.withclient.test.ts +++ b/test/sdk.withclient.test.ts @@ -1,11 +1,11 @@ -jest.spyOn(logger, 'error'); - import { setCurrentClient } from '@sentry/core'; import { logger } from '@sentry/utils'; import { flush } from '../src/js/sdk'; import { getDefaultTestClientOptions, TestClient } from './mocks/client'; +jest.spyOn(logger, 'error'); + describe('Tests the SDK functionality', () => { let client: TestClient; diff --git a/test/testutils.ts b/test/testutils.ts index e73b64a688..4548eef092 100644 --- a/test/testutils.ts +++ b/test/testutils.ts @@ -1,9 +1,6 @@ -import { Transaction } from '@sentry/core'; import type { Session, Transport, UserFeedback } from '@sentry/types'; import { rejectedSyncPromise } from '@sentry/utils'; -import { getBlankTransactionContext } from '../src/js/tracing/utils'; - export type MockInterface = { [K in keyof T]: T[K] extends (...args: infer A) => infer B ? jest.Mock : T[K]; } & T; @@ -13,15 +10,6 @@ export function mockFunction any>(fn: T): jest.Moc return fn as jest.MockedFunction; } -export const getMockTransaction = (name: string): Transaction => { - const transaction = new Transaction(getBlankTransactionContext(name)); - - // Assume it's sampled - transaction.sampled = true; - - return transaction; -}; - export const firstArg = 0; export const secondArg = 1; export const envelopeHeader = 0; diff --git a/test/tracing/integrations/stallTracking/stalltracking.iteration.test.ts b/test/tracing/integrations/stallTracking/stalltracking.iteration.test.ts index 95b8506a09..53c6c84439 100644 --- a/test/tracing/integrations/stallTracking/stalltracking.iteration.test.ts +++ b/test/tracing/integrations/stallTracking/stalltracking.iteration.test.ts @@ -1,8 +1,21 @@ import { stallTrackingIntegration } from '../../../../src/js/tracing/integrations/stalltracking'; +type StallTrackingWithTestProperties = ReturnType & { + isTracking: boolean; + _internalState: { + isBackground: boolean; + lastIntervalMs: number; + timeout: ReturnType | null; + stallCount: number; + totalStallTime: number; + statsByTransaction: Map; + iteration: () => void; + }; +}; + describe('Iteration', () => { it('Stall tracking does not set _timeout when isTracking is false', () => { - const stallTracking = stallTrackingIntegration(); + const stallTracking = stallTrackingIntegration() as StallTrackingWithTestProperties; stallTracking['isTracking'] = false; stallTracking['_internalState']['isBackground'] = false; stallTracking['_internalState']['lastIntervalMs'] = Date.now() - 1000; // Force a timeout @@ -12,7 +25,7 @@ describe('Iteration', () => { expect(stallTracking['_internalState']['timeout']).toBeNull(); }); it('Stall tracking does not set _timeout when isBackground is true', () => { - const stallTracking = stallTrackingIntegration(); + const stallTracking = stallTrackingIntegration() as StallTrackingWithTestProperties; stallTracking['isTracking'] = true; stallTracking['_internalState']['isBackground'] = true; stallTracking['_internalState']['lastIntervalMs'] = Date.now() - 1000; // Force a timeout @@ -22,7 +35,7 @@ describe('Iteration', () => { expect(stallTracking['_internalState']['timeout']).toBeNull(); }); it('Stall tracking should set _timeout when isTracking is true and isBackground false', () => { - const stallTracking = stallTrackingIntegration(); + const stallTracking = stallTrackingIntegration() as StallTrackingWithTestProperties; stallTracking['isTracking'] = true; stallTracking['_internalState']['isBackground'] = false; jest.useFakeTimers(); @@ -32,7 +45,7 @@ describe('Iteration', () => { expect(stallTracking['_internalState']['timeout']).toBeDefined(); }); it('Stall tracking should update _stallCount and _totalStallTime when timeout condition is met', () => { - const stallTracking = stallTrackingIntegration(); + const stallTracking = stallTrackingIntegration() as StallTrackingWithTestProperties; const LOOP_TIMEOUT_INTERVAL_MS = 50; const _minimumStallThreshold = 100; // Call _iteration with totalTimeTaken >= LOOP_TIMEOUT_INTERVAL_MS + _minimumStallThreshold diff --git a/test/vendor/base64-js/big-data.test.ts b/test/vendor/base64-js/big-data.test.ts index 750336b0d9..db22201baa 100644 --- a/test/vendor/base64-js/big-data.test.ts +++ b/test/vendor/base64-js/big-data.test.ts @@ -25,7 +25,8 @@ import { base64StringFromByteArray } from '../../../src/js/vendor'; describe('base64-js', () => { - test('convert big data to base64', () => { + // eslint-disable-next-line jest/no-disabled-tests + test.skip('convert big data to base64', () => { const SIZE_2MB = 2e6; // scaled down from original 64MiB const big = new Uint8Array(SIZE_2MB); for (let i = 0, length = big.length; i < length; ++i) { diff --git a/test/vendor/buffer/utf8ToBytes.test.ts b/test/vendor/buffer/utf8ToBytes.test.ts index 07fcdb8af2..6b743fdb54 100644 --- a/test/vendor/buffer/utf8ToBytes.test.ts +++ b/test/vendor/buffer/utf8ToBytes.test.ts @@ -73,9 +73,7 @@ describe('Buffer utf8 tests', () => { describe('test strings', () => { for (const input of testCases) { it(`should encode "${input}"`, () => { - // @ts-expect-error The test run in node where Buffer is available const actual = Buffer.from(utf8ToBytes(input)); - // @ts-expect-error The test run in node where Buffer is available const expected = Buffer.from(input, 'utf8'); expect(actual).toEqual(expected); From c69fa8996077bcbb7fc761decefd22cce166f00f Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 12 Aug 2024 10:56:23 +0200 Subject: [PATCH 45/51] add changelog --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 006b38690c..7460d527f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,44 @@ }); ``` +- New React Navigation Integration interface ([#4003](https://github.com/getsentry/sentry-react-native/pull/4003)) + + ```js + import Sentry from '@sentry/react-native'; + import { NavigationContainer } from '@react-navigation/native'; + + const reactNavigationIntegration = Sentry.reactNavigationIntegration(); + + Sentry.init({ + tracesSampleRate: 1.0, + integrations: [reactNavigationIntegration], + }); + + function RootComponent() { + const navigation = React.useRef(null); + + return { + reactNavigationIntegration.registerNavigationContainer(navigation); + }}> + ; + } + ``` + +- New React Native Navigation Integration interface ([#4003](https://github.com/getsentry/sentry-react-native/pull/4003)) + + ```js + import Sentry from '@sentry/react-native'; + import { Navigation } from 'react-native-navigation'; + + Sentry.init({ + tracesSampleRate: 1.0, + integrations: [ + Sentry.reactNativeNavigationIntegration({ navigation: Navigation }) + ], + }); + ``` + ### Fixes - Pass `sampleRate` option to the Android SDK ([#3979](https://github.com/getsentry/sentry-react-native/pull/3979)) From 5bc5c1e108663ccb225c1e61a516abf6c8e43d74 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 12 Aug 2024 11:41:24 +0200 Subject: [PATCH 46/51] fix ts and exports --- src/js/index.ts | 7 ++++++- src/js/tracing/index.ts | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/js/index.ts b/src/js/index.ts index 5244b7fbb7..6b60718744 100644 --- a/src/js/index.ts +++ b/src/js/index.ts @@ -59,13 +59,18 @@ export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; export { reactNativeTracingIntegration, + getCurrentReactNativeTracingIntegration, + getReactNativeTracingIntegration, reactNavigationIntegration, - ReactNativeNavigationInstrumentation, + reactNativeNavigationIntegration, sentryTraceGesture, TimeToInitialDisplay, TimeToFullDisplay, startTimeToInitialDisplaySpan, startTimeToFullDisplaySpan, + startIdleNavigationSpan, + startIdleSpan, + getDefaultIdleNavigationSpanOptions, } from './tracing'; export type { TimeToDisplayProps } from './tracing'; diff --git a/src/js/tracing/index.ts b/src/js/tracing/index.ts index e0bf02d7fe..446d2b82f5 100644 --- a/src/js/tracing/index.ts +++ b/src/js/tracing/index.ts @@ -1,11 +1,15 @@ export { reactNativeTracingIntegration, INTEGRATION_NAME as REACT_NATIVE_TRACING_INTEGRATION_NAME, + getCurrentReactNativeTracingIntegration, + getReactNativeTracingIntegration, } from './reactnativetracing'; export type { ReactNativeTracingIntegration } from './reactnativetracing'; export { reactNavigationIntegration } from './reactnavigation'; -export { ReactNativeNavigationInstrumentation } from './reactnativenavigation'; +export { reactNativeNavigationIntegration } from './reactnativenavigation'; + +export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span'; export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types'; From 740d33d56ee7066927b5eb2df6f59f59cdb48439 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 12 Aug 2024 11:54:51 +0200 Subject: [PATCH 47/51] fix circular deps --- src/js/tracing/reactnativenavigation.ts | 8 ++--- src/js/tracing/reactnativetracing.ts | 13 ++++----- src/js/tracing/reactnavigation.ts | 10 +++---- src/js/tracing/span.ts | 39 +++++++++++++++++++------ test/tracing/reactnavigation.test.ts | 2 +- 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/js/tracing/reactnativenavigation.ts b/src/js/tracing/reactnativenavigation.ts index decdde3f7f..adcddf77e1 100644 --- a/src/js/tracing/reactnativenavigation.ts +++ b/src/js/tracing/reactnativenavigation.ts @@ -9,12 +9,12 @@ import type { Client, Integration, Span } from '@sentry/types'; import type { EmitterSubscription } from '../utils/rnlibrariesinterface'; import { isSentrySpan } from '../utils/span'; import type { ReactNativeTracingIntegration } from './reactnativetracing'; +import { defaultReactNativeTracingOptions, getReactNativeTracingIntegration } from './reactnativetracing'; import { DEFAULT_NAVIGATION_SPAN_NAME, - defaultReactNativeTracingOptions, - getReactNativeTracingIntegration, -} from './reactnativetracing'; -import { getDefaultIdleNavigationSpanOptions, startIdleNavigationSpan as startGenericIdleNavigationSpan } from './span'; + getDefaultIdleNavigationSpanOptions, + startIdleNavigationSpan as startGenericIdleNavigationSpan, +} from './span'; export const INTEGRATION_NAME = 'ReactNativeNavigation'; diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 1d735b119c..7e66b5d30a 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -3,7 +3,7 @@ import { instrumentOutgoingRequests } from '@sentry/browser'; import { getClient } from '@sentry/core'; import type { Client, Event, Integration, StartSpanOptions } from '@sentry/types'; -import { addDefaultOpForSpanFrom } from './span'; +import { addDefaultOpForSpanFrom, defaultIdleOptions } from './span'; export const INTEGRATION_NAME = 'ReactNativeTracing'; @@ -14,7 +14,7 @@ export interface ReactNativeTracingOptions { * * @default 1_000 (ms) */ - idleTimeoutMs: number; + idleTimeoutMs?: number; /** * The max. time an idle span may run. @@ -22,7 +22,7 @@ export interface ReactNativeTracingOptions { * * @default 60_0000 (ms) */ - finalTimeoutMs: number; + finalTimeoutMs?: number; /** * Flag to disable patching all together for fetch requests. @@ -61,11 +61,8 @@ export interface ReactNativeTracingOptions { } const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; -export const DEFAULT_NAVIGATION_SPAN_NAME = 'Route Change'; export const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { - idleTimeoutMs: 1_000, - finalTimeoutMs: 60_0000, traceFetch: true, traceXHR: true, enableHTTPTimings: true, @@ -90,8 +87,8 @@ export const reactNativeTracingIntegration = ( ...defaultReactNativeTracingOptions, ...options, beforeStartSpan: options.beforeStartSpan ?? ((options: StartSpanOptions) => options), - finalTimeoutMs: options.finalTimeoutMs ?? defaultReactNativeTracingOptions.finalTimeoutMs, - idleTimeoutMs: options.idleTimeoutMs ?? defaultReactNativeTracingOptions.idleTimeoutMs, + finalTimeoutMs: options.finalTimeoutMs ?? defaultIdleOptions.finalTimeout, + idleTimeoutMs: options.idleTimeoutMs ?? defaultIdleOptions.idleTimeout, }; const setup = (client: Client): void => { diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index fdbe475c39..5c39c93164 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -17,13 +17,13 @@ import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { NATIVE } from '../wrapper'; import type { ReactNativeTracingIntegration } from './reactnativetracing'; +import { defaultReactNativeTracingOptions, getReactNativeTracingIntegration } from './reactnativetracing'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { DEFAULT_NAVIGATION_SPAN_NAME, - defaultReactNativeTracingOptions, - getReactNativeTracingIntegration, -} from './reactnativetracing'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; -import { getDefaultIdleNavigationSpanOptions, startIdleNavigationSpan as startGenericIdleNavigationSpan } from './span'; + getDefaultIdleNavigationSpanOptions, + startIdleNavigationSpan as startGenericIdleNavigationSpan, +} from './span'; import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan } from './timetodisplay'; export const INTEGRATION_NAME = 'ReactNavigation'; diff --git a/src/js/tracing/span.ts b/src/js/tracing/span.ts index 078f690dae..105bcaf800 100644 --- a/src/js/tracing/span.ts +++ b/src/js/tracing/span.ts @@ -14,19 +14,40 @@ import { generatePropagationContext, logger } from '@sentry/utils'; import { isRootSpan } from '../utils/span'; import { adjustTransactionDuration, cancelInBackground, ignoreEmptyBackNavigation } from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_INTERACTION } from './origin'; -import { DEFAULT_NAVIGATION_SPAN_NAME, defaultReactNativeTracingOptions } from './reactnativetracing'; + +export const DEFAULT_NAVIGATION_SPAN_NAME = 'Route Change'; + +export const defaultIdleOptions: { + /** + * 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) + */ + finalTimeout: number; + + /** + * 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) + */ + idleTimeout: number; + + ignoreEmptyBackNavigationTransactions: boolean; +} = { + idleTimeout: 1_000, + finalTimeout: 60_0000, + ignoreEmptyBackNavigationTransactions: true, +}; export const startIdleNavigationSpan = ( startSpanOption: StartSpanOptions, { - finalTimeout = defaultReactNativeTracingOptions.finalTimeoutMs, - idleTimeout = defaultReactNativeTracingOptions.idleTimeoutMs, - ignoreEmptyBackNavigationTransactions = true, - }: { - finalTimeout?: number; - idleTimeout?: number; - ignoreEmptyBackNavigationTransactions?: boolean; - } = {}, + finalTimeout = defaultIdleOptions.finalTimeout, + idleTimeout = defaultIdleOptions.idleTimeout, + ignoreEmptyBackNavigationTransactions = defaultIdleOptions.ignoreEmptyBackNavigationTransactions, + }: Partial = {}, ): Span | undefined => { const client = getClient(); if (!client) { diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index 142d58b75a..a7e3ba7a07 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -5,7 +5,6 @@ import { getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, setC import type { Event, Measurements, StartSpanOptions } from '@sentry/types'; 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 { reactNavigationIntegration } from '../../src/js/tracing/reactnavigation'; import { @@ -20,6 +19,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../../src/js/tracing/semanticAttributes'; +import { DEFAULT_NAVIGATION_SPAN_NAME } from '../../src/js/tracing/span'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { NATIVE } from '../mockWrapper'; From 189e81d49c8b395885da75728b6a6f7467c2f656 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 12 Aug 2024 12:18:31 +0200 Subject: [PATCH 48/51] fix defaults and clean up start nav span interface --- src/js/tracing/onSpanEndUtils.ts | 17 +++++++++--- src/js/tracing/reactnativenavigation.ts | 15 ++++++----- src/js/tracing/reactnavigation.ts | 35 +++++++++++-------------- src/js/tracing/span.ts | 20 +++++--------- 4 files changed, 45 insertions(+), 42 deletions(-) diff --git a/src/js/tracing/onSpanEndUtils.ts b/src/js/tracing/onSpanEndUtils.ts index 90c2acbe06..993d22c40f 100644 --- a/src/js/tracing/onSpanEndUtils.ts +++ b/src/js/tracing/onSpanEndUtils.ts @@ -7,7 +7,7 @@ import { AppState } from 'react-native'; import { isRootSpan, isSentrySpan } from '../utils/span'; /** - * + * Hooks on span end event to execute a callback when the span ends. */ export function onThisSpanEnd(client: Client, span: Span, callback: (span: Span) => void): void { client.on('spanEnd', (endedSpan: Span) => { @@ -44,7 +44,18 @@ export const adjustTransactionDuration = (client: Client, span: Span, maxDuratio } }); }; -export const ignoreEmptyBackNavigation = (client: Client, span: Span): void => { + +export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span): void => { + if (!client) { + logger.warn('Could not hook on spanEnd event because client is not defined.'); + return; + } + + if (!span) { + logger.warn('Could not hook on spanEnd event because span is not defined.'); + return; + } + if (!isRootSpan(span) || !isSentrySpan(span)) { logger.warn('Not sampling empty back spans only works for Sentry Transactions (Root Spans).'); return; @@ -70,7 +81,7 @@ export const ignoreEmptyBackNavigation = (client: Client, span: Span): void => { if (filtered.length <= 0) { // filter children must include at least one span not created by the navigation automatic instrumentation logger.log( - '[ReactNativeTracing] Not sampling transaction as route has been seen before. Pass ignoreEmptyBackNavigationTransactions = false to disable this feature.', + 'Not sampling transaction as route has been seen before. Pass ignoreEmptyBackNavigationTransactions = false to disable this feature.', ); // Route has been seen before and has no child spans. span['_sampled'] = false; diff --git a/src/js/tracing/reactnativenavigation.ts b/src/js/tracing/reactnativenavigation.ts index adcddf77e1..3f0378d96e 100644 --- a/src/js/tracing/reactnativenavigation.ts +++ b/src/js/tracing/reactnativenavigation.ts @@ -1,5 +1,6 @@ import { addBreadcrumb, + getClient, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, @@ -8,10 +9,12 @@ import type { Client, Integration, Span } from '@sentry/types'; import type { EmitterSubscription } from '../utils/rnlibrariesinterface'; import { isSentrySpan } from '../utils/span'; +import { ignoreEmptyBackNavigation } from './onSpanEndUtils'; import type { ReactNativeTracingIntegration } from './reactnativetracing'; -import { defaultReactNativeTracingOptions, getReactNativeTracingIntegration } from './reactnativetracing'; +import { getReactNativeTracingIntegration } from './reactnativetracing'; import { DEFAULT_NAVIGATION_SPAN_NAME, + defaultIdleOptions, getDefaultIdleNavigationSpanOptions, startIdleNavigationSpan as startGenericIdleNavigationSpan, } from './span'; @@ -102,11 +105,7 @@ export const reactNativeNavigationIntegration = ({ let recentComponentIds: string[] = []; let tracing: ReactNativeTracingIntegration | undefined; - let idleSpanOptions: Parameters[1] = { - finalTimeout: defaultReactNativeTracingOptions.finalTimeoutMs, - idleTimeout: defaultReactNativeTracingOptions.idleTimeoutMs, - ignoreEmptyBackNavigationTransactions, - }; + let idleSpanOptions: Parameters[1] = defaultIdleOptions; let stateChangeTimeout: ReturnType | undefined; let prevComponentEvent: ComponentWillAppearEvent | null = null; @@ -118,7 +117,6 @@ export const reactNativeNavigationIntegration = ({ idleSpanOptions = { finalTimeout: tracing.options.finalTimeoutMs, idleTimeout: tracing.options.idleTimeoutMs, - ignoreEmptyBackNavigationTransactions, }; } }; @@ -134,6 +132,9 @@ export const reactNativeNavigationIntegration = ({ : getDefaultIdleNavigationSpanOptions(), idleSpanOptions, ); + if (ignoreEmptyBackNavigationTransactions) { + ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); + } stateChangeTimeout = setTimeout(discardLatestNavigationSpan.bind(this), routeChangeTimeoutMs); }; diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index 5c39c93164..bdf209e0fd 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -2,6 +2,7 @@ import { addBreadcrumb, getActiveSpan, + getClient, SEMANTIC_ATTRIBUTE_SENTRY_OP, setMeasurement, SPAN_STATUS_OK, @@ -16,11 +17,13 @@ import { type SentryEventEmitter, createSentryEventEmitter, NewFrameEventName } import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { NATIVE } from '../wrapper'; +import { ignoreEmptyBackNavigation } from './onSpanEndUtils'; import type { ReactNativeTracingIntegration } from './reactnativetracing'; -import { defaultReactNativeTracingOptions, getReactNativeTracingIntegration } from './reactnativetracing'; +import { getReactNativeTracingIntegration } from './reactnativetracing'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { DEFAULT_NAVIGATION_SPAN_NAME, + defaultIdleOptions, getDefaultIdleNavigationSpanOptions, startIdleNavigationSpan as startGenericIdleNavigationSpan, } from './span'; @@ -79,11 +82,7 @@ export const reactNavigationIntegration = ({ let newScreenFrameEventEmitter: SentryEventEmitter | undefined; let tracing: ReactNativeTracingIntegration | undefined; - let idleSpanOptions: Parameters[1] = { - finalTimeout: defaultReactNativeTracingOptions.finalTimeoutMs, - idleTimeout: defaultReactNativeTracingOptions.idleTimeoutMs, - ignoreEmptyBackNavigationTransactions, - }; + let idleSpanOptions: Parameters[1] = defaultIdleOptions; let latestRoute: NavigationRoute | undefined; let latestNavigationSpan: Span | undefined; @@ -97,7 +96,7 @@ export const reactNavigationIntegration = ({ newScreenFrameEventEmitter = createSentryEventEmitter(); newScreenFrameEventEmitter.initAsync(NewFrameEventName); NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => { - logger.error(`[ReactNavigationInstrumentation] Failed to initialize native new frame tracking: ${reason}`); + logger.error(`${INTEGRATION_NAME} Failed to initialize native new frame tracking: ${reason}`); }); } @@ -110,7 +109,6 @@ export const reactNavigationIntegration = ({ idleSpanOptions = { finalTimeout: tracing.options.finalTimeoutMs, idleTimeout: tracing.options.idleTimeoutMs, - ignoreEmptyBackNavigationTransactions, }; } @@ -139,7 +137,7 @@ export const reactNavigationIntegration = ({ */ if (RN_GLOBAL_OBJ.__sentry_rn_v5_registered) { logger.log( - '[ReactNavigationInstrumentation] Instrumentation already exists, but register has been called again, doing nothing.', + `${INTEGRATION_NAME} Instrumentation already exists, but register has been called again, doing nothing.`, ); return undefined; } @@ -150,7 +148,7 @@ export const reactNavigationIntegration = ({ navigationContainer = navigationContainerRef as NavigationContainer; } if (!navigationContainer) { - logger.warn('[ReactNavigationInstrumentation] Received invalid navigation container ref!'); + logger.warn(`${INTEGRATION_NAME} Received invalid navigation container ref!`); return undefined; } @@ -164,9 +162,7 @@ export const reactNavigationIntegration = ({ } if (!latestNavigationSpan) { - logger.log( - '[ReactNavigationInstrumentation] Navigation container registered, but integration has not been setup yet.', - ); + logger.log(`${INTEGRATION_NAME} Navigation container registered, but integration has not been setup yet.`); return undefined; } @@ -184,9 +180,7 @@ export const reactNavigationIntegration = ({ */ const startIdleNavigationSpan = (): void => { if (latestNavigationSpan) { - logger.log( - '[ReactNavigationInstrumentation] A transaction was detected that turned out to be a noop, discarding.', - ); + logger.log(`${INTEGRATION_NAME} A transaction was detected that turned out to be a noop, discarding.`); _discardLatestTransaction(); clearStateChangeTimeout(); } @@ -197,6 +191,9 @@ export const reactNavigationIntegration = ({ : getDefaultIdleNavigationSpanOptions(), idleSpanOptions, ); + if (ignoreEmptyBackNavigationTransactions) { + ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); + } if (enableTimeToInitialDisplay) { navigationProcessingSpan = startInactiveSpan({ @@ -257,12 +254,12 @@ export const reactNavigationIntegration = ({ newScreenFrameEventEmitter?.once(NewFrameEventName, ({ newFrameTimestampInSeconds }: NewFrameEvent) => { const activeSpan = getActiveSpan(); if (!activeSpan) { - logger.warn('[ReactNavigationInstrumentation] No active span found to attach ui.load.initial_display to.'); + logger.warn(`${INTEGRATION_NAME} No active span found to attach ui.load.initial_display to.`); return; } if (manualInitialDisplaySpans.has(activeSpan)) { - logger.warn('[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.'); + logger.warn(`${INTEGRATION_NAME} Detected manual instrumentation for the current active span.`); return; } @@ -272,7 +269,7 @@ export const reactNavigationIntegration = ({ if (spanToJSON(latestTtidSpan).parent_span_id !== getActiveSpan()?.spanContext().spanId) { logger.warn( - '[ReactNavigationInstrumentation] Currently Active Span changed before the new frame was rendered, _latestTtidSpan is not a child of the currently active span.', + `${INTEGRATION_NAME} Currently Active Span changed before the new frame was rendered, _latestTtidSpan is not a child of the currently active span.`, ); return; } diff --git a/src/js/tracing/span.ts b/src/js/tracing/span.ts index 105bcaf800..1a9abeb0ad 100644 --- a/src/js/tracing/span.ts +++ b/src/js/tracing/span.ts @@ -12,7 +12,7 @@ import type { Client, Scope, Span, StartSpanOptions } from '@sentry/types'; import { generatePropagationContext, logger } from '@sentry/utils'; import { isRootSpan } from '../utils/span'; -import { adjustTransactionDuration, cancelInBackground, ignoreEmptyBackNavigation } from './onSpanEndUtils'; +import { adjustTransactionDuration, cancelInBackground } from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_INTERACTION } from './origin'; export const DEFAULT_NAVIGATION_SPAN_NAME = 'Route Change'; @@ -33,12 +33,9 @@ export const defaultIdleOptions: { * @default 60_0000 (ms) */ idleTimeout: number; - - ignoreEmptyBackNavigationTransactions: boolean; } = { idleTimeout: 1_000, finalTimeout: 60_0000, - ignoreEmptyBackNavigationTransactions: true, }; export const startIdleNavigationSpan = ( @@ -46,19 +43,20 @@ export const startIdleNavigationSpan = ( { finalTimeout = defaultIdleOptions.finalTimeout, idleTimeout = defaultIdleOptions.idleTimeout, - ignoreEmptyBackNavigationTransactions = defaultIdleOptions.ignoreEmptyBackNavigationTransactions, }: Partial = {}, ): Span | undefined => { const client = getClient(); if (!client) { - logger.warn(`[ReactNativeTracing] Can't create route change span, missing client.`); + logger.warn(`[startIdleNavigationSpan] 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.`, + `[startIdleNavigationSpan] Canceling ${ + spanToJSON(activeSpan).op + } transaction because of a new navigation root span.`, ); activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); activeSpan.end(); @@ -71,16 +69,12 @@ export const startIdleNavigationSpan = ( const idleSpan = startIdleSpan(finalStartStapOptions, { finalTimeout, idleTimeout }); logger.log( - `[ReactNativeTracing] Starting ${finalStartStapOptions.op || 'unknown op'} transaction "${ + `[startIdleNavigationSpan] Starting ${finalStartStapOptions.op || 'unknown op'} transaction "${ finalStartStapOptions.name }" on scope`, ); adjustTransactionDuration(client, idleSpan, finalTimeout); - if (ignoreEmptyBackNavigationTransactions) { - ignoreEmptyBackNavigation(client, idleSpan); - } - return idleSpan; }; @@ -97,7 +91,7 @@ export const startIdleSpan = ( ): Span => { const client = getClient(); if (!client) { - logger.warn(`[ReactNativeTracing] Can't create idle span, missing client.`); + logger.warn(`[startIdleSpan] Can't create idle span, missing client.`); return new SentryNonRecordingSpan(); } From bc250093f6d27845f1a981c9bce471f154820f21 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 12 Aug 2024 13:10:21 +0200 Subject: [PATCH 49/51] fix lint --- test/client.test.ts | 10 +++++----- test/integrations/spotlight.test.ts | 3 +-- test/scopeSync.test.ts | 1 + .../tracing/integrations/nativeframes.test.ts | 4 ++-- .../stalltracking.background.test.ts | 19 +++++++++++++++---- test/tracing/mockedtimetodisplaynative.tsx | 2 +- test/tracing/reactnavigation.ttid.test.tsx | 18 +++++++++--------- test/tracing/timetodisplay.test.tsx | 4 ++-- test/utils/mockedSentryeventemitter.ts | 2 +- test/utils/safe.test.ts | 8 ++++---- test/wrapper.test.ts | 18 ++++++++++++------ 11 files changed, 53 insertions(+), 36 deletions(-) diff --git a/test/client.test.ts b/test/client.test.ts index 69a7a2436b..de154e094c 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -406,7 +406,7 @@ describe('Tests ReactNativeClient', () => { describe('event data enhancement', () => { test('event contains sdk default information', async () => { - const mockedSend = jest.fn, [Envelope]>().mockResolvedValue(undefined); + const mockedSend = jest.fn, [Envelope]>().mockResolvedValue({}); const mockedTransport = (): Transport => ({ send: mockedSend, flush: jest.fn().mockResolvedValue(true), @@ -434,7 +434,7 @@ describe('Tests ReactNativeClient', () => { describe('normalizes events', () => { test('handles circular input', async () => { - const mockedSend = jest.fn, [Envelope]>(); + const mockedSend = jest.fn, [Envelope]>().mockResolvedValue({}); const mockedTransport = (): Transport => ({ send: mockedSend, flush: jest.fn().mockResolvedValue(true), @@ -467,7 +467,7 @@ describe('Tests ReactNativeClient', () => { describe('clientReports', () => { test('does not send client reports if disabled', () => { - const mockTransportSend = jest.fn((_envelope: Envelope) => Promise.resolve()); + const mockTransportSend = jest.fn, [Envelope]>().mockResolvedValue({}); const client = new ReactNativeClient({ ...DEFAULT_OPTIONS, dsn: EXAMPLE_DSN, @@ -486,7 +486,7 @@ describe('Tests ReactNativeClient', () => { }); test('send client reports on event envelope', () => { - const mockTransportSend = jest.fn((_envelope: Envelope) => Promise.resolve()); + const mockTransportSend = jest.fn, [Envelope]>().mockResolvedValue({}); const client = new ReactNativeClient({ ...DEFAULT_OPTIONS, dsn: EXAMPLE_DSN, @@ -520,7 +520,7 @@ describe('Tests ReactNativeClient', () => { }); test('does not send empty client report', () => { - const mockTransportSend = jest.fn((_envelope: Envelope) => Promise.resolve()); + const mockTransportSend = jest.fn, [Envelope]>().mockResolvedValue({}); const client = new ReactNativeClient({ ...DEFAULT_OPTIONS, dsn: EXAMPLE_DSN, diff --git a/test/integrations/spotlight.test.ts b/test/integrations/spotlight.test.ts index 13bdfaf6c9..306580d669 100644 --- a/test/integrations/spotlight.test.ts +++ b/test/integrations/spotlight.test.ts @@ -1,11 +1,10 @@ import type { HttpRequestEventMap } from '@mswjs/interceptors'; import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest'; import type { Client, Envelope } from '@sentry/types'; -import { XMLHttpRequest } from 'xmlhttprequest'; import { spotlightIntegration } from '../../src/js/integrations/spotlight'; -globalThis.XMLHttpRequest = XMLHttpRequest; +globalThis.XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest; const requestListener = jest.fn(); const interceptor = new XMLHttpRequestInterceptor(); interceptor.on('request', requestListener); diff --git a/test/scopeSync.test.ts b/test/scopeSync.test.ts index e6f7e161fe..fc0635aa95 100644 --- a/test/scopeSync.test.ts +++ b/test/scopeSync.test.ts @@ -179,6 +179,7 @@ describe('ScopeSync', () => { it('addBreadcrumb', () => { expect(SentryCore.getIsolationScope().addBreadcrumb).not.toBe(addBreadcrumbScopeSpy); + SentryCore.getIsolationScope().getLastBreadcrumb = jest.fn(() => ({ message: 'test' })); SentryCore.addBreadcrumb({ message: 'test' }); expect(NATIVE.addBreadcrumb).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ message: 'test' })); diff --git a/test/tracing/integrations/nativeframes.test.ts b/test/tracing/integrations/nativeframes.test.ts index bcab5bcb2e..7c483b0b0a 100644 --- a/test/tracing/integrations/nativeframes.test.ts +++ b/test/tracing/integrations/nativeframes.test.ts @@ -156,7 +156,7 @@ describe('NativeFramesInstrumentation', () => { }); it('does not set measurements on transactions without startFrames', async () => { - const startFrames = null; + const startFrames: null = null; const finishFrames = { totalFrames: 200, slowFrames: 40, @@ -191,7 +191,7 @@ describe('NativeFramesInstrumentation', () => { slowFrames: 20, frozenFrames: 5, }; - const finishFrames = null; + const finishFrames: null = null; mockFunction(NATIVE.fetchNativeFrames).mockResolvedValueOnce(startFrames).mockResolvedValueOnce(finishFrames); await startSpan({ name: 'test' }, async () => { diff --git a/test/tracing/integrations/stallTracking/stalltracking.background.test.ts b/test/tracing/integrations/stallTracking/stalltracking.background.test.ts index c8174e3c3f..2358c77d1e 100644 --- a/test/tracing/integrations/stallTracking/stalltracking.background.test.ts +++ b/test/tracing/integrations/stallTracking/stalltracking.background.test.ts @@ -2,9 +2,20 @@ import type { AppStateStatus } from 'react-native'; import { stallTrackingIntegration } from '../../../../src/js/tracing/integrations/stalltracking'; +type StallTrackingWithTestProperties = ReturnType & { + isTracking: boolean; + _internalState: { + isBackground: boolean; + lastIntervalMs: number; + timeout: ReturnType | null; + iteration: () => void; + backgroundEventListener: (event: string) => void; + }; +}; + 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 stallTracking = stallTrackingIntegration() as StallTrackingWithTestProperties; const LOOP_TIMEOUT_INTERVAL_MS = 500; // Change this value based on your actual interval value const currentTime = Date.now(); stallTracking['_internalState']['lastIntervalMs'] = currentTime; @@ -18,14 +29,14 @@ describe('BackgroundEventListener', () => { 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(); + const stallTracking = stallTrackingIntegration() as StallTrackingWithTestProperties; 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(); + const stallTracking = stallTrackingIntegration() as StallTrackingWithTestProperties; stallTracking['_internalState']['timeout'] = null; // Mock _iteration stallTracking['_internalState']['iteration'] = jest.fn(); @@ -35,7 +46,7 @@ describe('BackgroundEventListener', () => { expect(stallTracking['_internalState']['iteration']).not.toBeCalled(); }); it('Stall tracking should call _iteration when state is active and _timeout is defined', () => { - const stallTracking = stallTrackingIntegration(); + const stallTracking = stallTrackingIntegration() as StallTrackingWithTestProperties; stallTracking['_internalState']['timeout'] = setTimeout(() => {}, 500); // Mock _iteration stallTracking['_internalState']['iteration'] = jest.fn(); // Create a fake timeout to simulate a running interval diff --git a/test/tracing/mockedtimetodisplaynative.tsx b/test/tracing/mockedtimetodisplaynative.tsx index 82d3ac822d..14c78fc5e0 100644 --- a/test/tracing/mockedtimetodisplaynative.tsx +++ b/test/tracing/mockedtimetodisplaynative.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { View } from 'react-native'; import type { RNSentryOnDrawNextFrameEvent, RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types'; diff --git a/test/tracing/reactnavigation.ttid.test.tsx b/test/tracing/reactnavigation.ttid.test.tsx index 5088e4e5da..ed6a997d0b 100644 --- a/test/tracing/reactnavigation.ttid.test.tsx +++ b/test/tracing/reactnavigation.ttid.test.tsx @@ -1,3 +1,8 @@ +import type { SpanJSON, TransactionEvent, Transport } from '@sentry/types'; +import { timestampInSeconds } from '@sentry/utils'; +import * as TestRenderer from '@testing-library/react-native' +import * as React from "react"; + import * as mockWrapper from '../mockWrapper'; import * as mockedSentryEventEmitter from '../utils/mockedSentryeventemitter'; import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; @@ -6,11 +11,6 @@ jest.mock('../../src/js/utils/environment'); jest.mock('../../src/js/utils/sentryeventemitter', () => mockedSentryEventEmitter); jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); -import type { SpanJSON, TransactionEvent, Transport } from '@sentry/types'; -import { timestampInSeconds } from '@sentry/utils'; -import React from "react"; -import TestRenderer from 'react-test-renderer'; - import * as Sentry from '../../src/js'; import { TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing'; import { _setAppStartEndTimestampMs } from '../../src/js/tracing/integrations/appStart'; @@ -196,7 +196,7 @@ describe('React Navigation - TTID', () => { mockedNavigation.finishAppStartNavigation(); mockedEventEmitter.emitNewFrameEvent(); - TestRenderer.create(); + TestRenderer.render(); emitNativeFullDisplayEvent(); jest.runOnlyPendingTimers(); // Flush ttid transaction @@ -265,7 +265,7 @@ describe('React Navigation - TTID', () => { mockedNavigation.finishAppStartNavigation(); mockedEventEmitter.emitNewFrameEvent(); - TestRenderer.create(); + TestRenderer.render(); emitNativeFullDisplayEvent(); jest.runOnlyPendingTimers(); // Flush ttid transaction @@ -363,7 +363,7 @@ describe('React Navigation - TTID', () => { jest.runOnlyPendingTimers(); // Flush app start transaction mockedNavigation.navigateToNewScreen(); - const timeToDisplayComponent = TestRenderer.create(); + const timeToDisplayComponent = TestRenderer.render(); mockedEventEmitter.emitNewFrameEvent(); timeToDisplayComponent.update(); @@ -400,7 +400,7 @@ describe('React Navigation - TTID', () => { mockedEventEmitter.emitNewFrameEvent(autoInitialDisplayEndTimestampMs); // Initialized too late auto instrumentation finished before manual - TestRenderer.create(); + TestRenderer.render(); emitNativeInitialDisplayEvent(secondInFutureTimestampMs()); jest.runOnlyPendingTimers(); // Flush transaction diff --git a/test/tracing/timetodisplay.test.tsx b/test/tracing/timetodisplay.test.tsx index 11171bdde9..80d68211d6 100644 --- a/test/tracing/timetodisplay.test.tsx +++ b/test/tracing/timetodisplay.test.tsx @@ -3,8 +3,8 @@ jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplayn import { getCurrentScope, getGlobalScope, getIsolationScope, getSpanDescendants, setCurrentClient, spanToJSON, startSpanManual} from '@sentry/core'; import type { Event, Measurements, Span, SpanJSON} from '@sentry/types'; -import React from "react"; -import TestRenderer from 'react-test-renderer'; +import * as React from "react"; +import * as TestRenderer from 'react-test-renderer'; import { startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing/timetodisplay'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; diff --git a/test/utils/mockedSentryeventemitter.ts b/test/utils/mockedSentryeventemitter.ts index 3efc901b55..6849e460be 100644 --- a/test/utils/mockedSentryeventemitter.ts +++ b/test/utils/mockedSentryeventemitter.ts @@ -1,5 +1,5 @@ import { timestampInSeconds } from '@sentry/utils'; -import EventEmitter from 'events'; +import * as EventEmitter from 'events'; import type { NewFrameEvent, SentryEventEmitter } from '../../src/js/utils/sentryeventemitter'; import type { MockInterface } from '../testutils'; diff --git a/test/utils/safe.test.ts b/test/utils/safe.test.ts index a2e887122d..89e063bd35 100644 --- a/test/utils/safe.test.ts +++ b/test/utils/safe.test.ts @@ -38,14 +38,14 @@ describe('safe', () => { test('calls given function with correct args', () => { const mockFn = jest.fn(); const actualSafeFunction = safeTracesSampler(mockFn); - actualSafeFunction?.({ transactionContext: { name: 'foo' } }); + actualSafeFunction?.({ name: 'foo', transactionContext: { name: 'foo' } }); expect(mockFn).toBeCalledTimes(1); - expect(mockFn).toBeCalledWith({ transactionContext: { name: 'foo' } }); + expect(mockFn).toBeCalledWith({ name: 'foo', transactionContext: { name: 'foo' } }); }); test('calls given function amd return its result', () => { const mockFn = jest.fn(() => 0.5); const actualSafeFunction = safeTracesSampler(mockFn); - const actualResult = actualSafeFunction?.({ transactionContext: { name: 'foo' } }); + const actualResult = actualSafeFunction?.({ name: 'foo', transactionContext: { name: 'foo' } }); expect(mockFn).toBeCalledTimes(1); expect(actualResult).toBe(0.5); }); @@ -58,7 +58,7 @@ describe('safe', () => { throw 'Test error'; }); const actualSafeFunction = safeTracesSampler(mockFn); - const actualResult = actualSafeFunction?.({ transactionContext: { name: 'foo' } }); + const actualResult = actualSafeFunction?.({ name: 'foo', transactionContext: { name: 'foo' } }); expect(mockFn).toBeCalledTimes(1); expect(actualResult).toEqual(0); }); diff --git a/test/wrapper.test.ts b/test/wrapper.test.ts index 884fd1a83c..524fc0e604 100644 --- a/test/wrapper.test.ts +++ b/test/wrapper.test.ts @@ -96,7 +96,7 @@ describe('Tests Native Wrapper', () => { describe('startWithOptions', () => { test('calls native module', async () => { - await NATIVE.initNativeSdk({ dsn: 'test', enableNative: true }); + await NATIVE.initNativeSdk({ dsn: 'test', enableNative: true, mobileReplayOptions: undefined }); expect(RNSentry.initNativeSdk).toBeCalled(); }); @@ -104,7 +104,7 @@ describe('Tests Native Wrapper', () => { test('warns if there is no dsn', async () => { logger.warn = jest.fn(); - await NATIVE.initNativeSdk({ enableNative: true }); + await NATIVE.initNativeSdk({ enableNative: true, mobileReplayOptions: undefined }); expect(RNSentry.initNativeSdk).not.toBeCalled(); expect(logger.warn).toHaveBeenLastCalledWith( @@ -119,6 +119,7 @@ describe('Tests Native Wrapper', () => { dsn: 'test', enableNative: false, enableNativeNagger: true, + mobileReplayOptions: undefined, }); expect(RNSentry.initNativeSdk).not.toBeCalled(); @@ -132,6 +133,7 @@ describe('Tests Native Wrapper', () => { enableNative: true, autoInitializeNativeSdk: true, beforeSend: jest.fn(), + mobileReplayOptions: undefined, }); expect(RNSentry.initNativeSdk).toBeCalled(); @@ -147,6 +149,7 @@ describe('Tests Native Wrapper', () => { enableNative: true, autoInitializeNativeSdk: true, beforeBreadcrumb: jest.fn(), + mobileReplayOptions: undefined, }); expect(RNSentry.initNativeSdk).toBeCalled(); @@ -162,6 +165,7 @@ describe('Tests Native Wrapper', () => { enableNative: true, autoInitializeNativeSdk: true, beforeSendTransaction: jest.fn(), + mobileReplayOptions: undefined, }); expect(RNSentry.initNativeSdk).toBeCalled(); @@ -177,6 +181,7 @@ describe('Tests Native Wrapper', () => { enableNative: true, autoInitializeNativeSdk: true, integrations: [], + mobileReplayOptions: undefined, }); expect(RNSentry.initNativeSdk).toBeCalled(); @@ -194,6 +199,7 @@ describe('Tests Native Wrapper', () => { dsn: 'test', enableNative: true, autoInitializeNativeSdk: false, + mobileReplayOptions: undefined, }); expect(RNSentry.initNativeSdk).not.toBeCalled(); @@ -232,6 +238,7 @@ describe('Tests Native Wrapper', () => { logger.warn = jest.fn(); await NATIVE.initNativeSdk({ + mobileReplayOptions: undefined, dsn: 'test', enableNative: false, autoInitializeNativeSdk: false, @@ -282,7 +289,7 @@ describe('Tests Native Wrapper', () => { test('serializes class instances', async () => { class TestInstance { value: number = 0; - method = () => null; + method = (): null => null; } const event = { @@ -313,7 +320,7 @@ describe('Tests Native Wrapper', () => { }); test('does not call RNSentry at all if enableNative is false', async () => { try { - await NATIVE.initNativeSdk({ dsn: 'test-dsn', enableNative: false }); + await NATIVE.initNativeSdk({ dsn: 'test-dsn', enableNative: false, mobileReplayOptions: undefined }); // @ts-expect-error for testing, does not accept an empty class. await NATIVE.sendEnvelope({}); @@ -505,7 +512,7 @@ describe('Tests Native Wrapper', () => { expect(RNSentry.crash).toBeCalled(); }); test('does not call crash if enableNative is false', async () => { - await NATIVE.initNativeSdk({ dsn: 'test-dsn', enableNative: false }); + await NATIVE.initNativeSdk({ dsn: 'test-dsn', enableNative: false, mobileReplayOptions: undefined }); NATIVE.nativeCrash(); expect(RNSentry.crash).not.toBeCalled(); @@ -516,7 +523,6 @@ describe('Tests Native Wrapper', () => { test('serializes all user object keys', async () => { NATIVE.setUser({ email: 'hello@sentry.io', - // @ts-expect-error Intentional incorrect type to simulate using a double as an id (We had a user open an issue because this didn't work before) id: 3.14159265359, unique: 123, }); From c9706e3d69b850291634093db6d5fb9f312238ed Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 12 Aug 2024 13:13:03 +0200 Subject: [PATCH 50/51] fix missing package --- package.json | 4 +++- yarn.lock | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 030b84aa9e..246f8fc6b8 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@sentry-internal/eslint-plugin-sdk": "8.11.0", "@sentry-internal/typescript": "8.11.0", "@sentry/wizard": "3.16.3", + "@testing-library/react-native": "^12.5.3", "@types/jest": "^29.5.3", "@types/node": "^20.9.3", "@types/react": "^18.2.64", @@ -128,5 +129,6 @@ "expo": { "optional": true } - } + }, + "packageManager": "yarn@1.22.22" } diff --git a/yarn.lock b/yarn.lock index 0daf8b0b61..1f39d8e672 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4054,6 +4054,15 @@ "@types/react-test-renderer" ">=16.9.0" react-error-boundary "^3.1.0" +"@testing-library/react-native@^12.5.3": + version "12.5.3" + resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-12.5.3.tgz#0ea753efed505698a79ba4a0d42d4b79cd272abc" + integrity sha512-wSaplzjx51OVJI7MU8Mi2kxwfW0dYETn3jqSVHxtIXmEnmlWXk6f69sEaBbzdp6iDzhFB5E6rDWveqf5V/ap2A== + dependencies: + jest-matcher-utils "^29.7.0" + pretty-format "^29.7.0" + redent "^3.0.0" + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -8972,7 +8981,7 @@ jest-config@^29.6.2: slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^29.0.0: +jest-diff@^29.0.0, jest-diff@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== @@ -9179,6 +9188,16 @@ jest-matcher-utils@^29.6.2: jest-get-type "^29.4.3" pretty-format "^29.6.2" +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== + dependencies: + chalk "^4.0.0" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-message-util@^29.3.1: version "29.3.1" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.3.1.tgz#37bc5c468dfe5120712053dd03faf0f053bd6adb" @@ -10600,6 +10619,11 @@ mimic-fn@^4.0.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + "minimatch@2 || 3", minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -12066,6 +12090,14 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + reflect.getprototypeof@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz#aaccbf41aca3821b87bb71d9dcbc7ad0ba50a3f3" @@ -13177,6 +13209,13 @@ strip-final-newline@^3.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" From 441abad07f3fe79ce9ff6fb12a5d637ee5b92871 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Mon, 12 Aug 2024 13:24:36 +0200 Subject: [PATCH 51/51] add expo config type test --- src/js/tools/metroconfig.ts | 3 ++- test/tools/metroconfig.test.ts | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/js/tools/metroconfig.ts b/src/js/tools/metroconfig.ts index 6e58544757..9922924910 100644 --- a/src/js/tools/metroconfig.ts +++ b/src/js/tools/metroconfig.ts @@ -1,5 +1,6 @@ import { logger } from '@sentry/utils'; -import type { MetroConfig, MixedOutput, Module, ReadOnlyGraph } from 'metro'; +import type { MixedOutput, Module, ReadOnlyGraph } from 'metro'; +import type { MetroConfig } from 'metro-config'; import * as process from 'process'; import { env } from 'process'; diff --git a/test/tools/metroconfig.test.ts b/test/tools/metroconfig.test.ts index a0ee9533ff..53ab6952e7 100644 --- a/test/tools/metroconfig.test.ts +++ b/test/tools/metroconfig.test.ts @@ -6,12 +6,17 @@ jest.mock('fs', () => { }; }); +import type { getDefaultConfig } from 'expo/metro-config'; import * as fs from 'fs'; import type { MetroConfig } from 'metro'; import * as path from 'path'; import * as process from 'process'; -import { withSentryBabelTransformer, withSentryFramesCollapsed } from '../../src/js/tools/metroconfig'; +import { + getSentryExpoConfig, + withSentryBabelTransformer, + withSentryFramesCollapsed, +} from '../../src/js/tools/metroconfig'; type MetroFrame = Parameters['symbolicator']>['customizeFrame']>[0]; @@ -20,6 +25,13 @@ describe('metroconfig', () => { jest.clearAllMocks(); }); + test('getSentryExpoConfig keeps compatible interface with Expos getDefaultConfig', () => { + const acceptsExpoDefaultConfigFactory = (_factory: typeof getDefaultConfig): void => { + expect(true).toBe(true); + }; + acceptsExpoDefaultConfigFactory(getSentryExpoConfig); + }); + describe('withSentryFramesCollapsed', () => { test('adds customizeFrames if undefined ', () => { const config = withSentryFramesCollapsed({});